From 0582bb183b246e63c8382d7330856aaf93eae0fc Mon Sep 17 00:00:00 2001 From: Nadav Schachter Date: Sun, 8 Feb 2026 21:46:40 +0200 Subject: [PATCH 01/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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'. """ From 1f8a80e24d500704d68fad116da222874998d9d5 Mon Sep 17 00:00:00 2001 From: Nadav Schachter Date: Tue, 24 Feb 2026 16:25:00 +0200 Subject: [PATCH 22/29] refactor(find): replace --next-token/--type/--max-items with auto-pagination and -P params - Interactive mode: pages 50 items at a time with 'Press any key to continue...' - Command-line mode: fetches up to 1000 items in a single request - Replace --type with -P type=Report,Lakehouse (key=value pattern) - Remove --max-items and --next-token flags Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/fabric_cli/commands/find/fab_find.py | 218 +++++++++++++--------- src/fabric_cli/parsers/fab_find_parser.py | 47 +---- 2 files changed, 135 insertions(+), 130 deletions(-) diff --git a/src/fabric_cli/commands/find/fab_find.py b/src/fabric_cli/commands/find/fab_find.py index c2c9c08f..d20edf5a 100644 --- a/src/fabric_cli/commands/find/fab_find.py +++ b/src/fabric_cli/commands/find/fab_find.py @@ -70,84 +70,149 @@ SEARCHABLE_ITEM_TYPES = [t for t in ALL_ITEM_TYPES if t not in UNSUPPORTED_ITEM_TYPES] -def complete_item_types(prefix: str, **kwargs) -> list[str]: - """Completer for --type flag. Returns matching searchable item types.""" - prefix_lower = prefix.lower() - # Only complete searchable types to avoid user frustration - return [t for t in SEARCHABLE_ITEM_TYPES if t.lower().startswith(prefix_lower)] - - @handle_exceptions() @set_command_context() def find_command(args: Namespace) -> None: """Search the Fabric catalog for items.""" - # Validate: either query or --next-token must be provided - has_query = hasattr(args, "query") and args.query - has_continue = hasattr(args, "continue_token") and args.continue_token - - if not has_query and not has_continue: - raise FabricCLIError( - "Either a search query or --next-token is required.", - fab_constant.ERROR_INVALID_INPUT, - ) + is_interactive = getattr(args, "fab_mode", None) == fab_constant.FAB_MODE_INTERACTIVE + payload = _build_search_payload(args, is_interactive) - 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...") + if is_interactive: + _find_interactive(args, payload) else: - utils_ui.print_grey(f"Searching catalog for '{args.query}'...") + _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") + + 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 up to 1000 results in a single request and display.""" response = catalog_api.catalog_search(args, payload) + _raise_on_error(response) - _handle_response(args, response) + results = json.loads(response.text) + items = results.get("value", []) + + if not items: + utils_ui.print_grey("No items found.") + return + utils_ui.print_grey("") + utils_ui.print_grey(f"{len(items)} item(s) found") + utils_ui.print_grey("") -def _build_search_payload(args: Namespace) -> dict[str, Any]: + _display_items(args, 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] = {} - - # If continuation token is provided, only send that (search/filter are encoded in token) - if hasattr(args, "continue_token") and args.continue_token: - request["continuationToken"] = args.continue_token - # Add page size if specified (allowed with continuation token) - if hasattr(args, "limit") and args.limit: - request["pageSize"] = args.limit - return request - - # Normal search request - request["search"] = args.query - - # Add page size if specified - if hasattr(args, "limit") and args.limit: - request["pageSize"] = args.limit - - # Build type filter if specified - if hasattr(args, "type") and args.type: - types = args.type # Already a list from argparse nargs="+" - # Validate types - for t in types: - if t in UNSUPPORTED_ITEM_TYPES: - raise FabricCLIError( - f"Item type '{t}' is not searchable via catalog search API. " - f"Unsupported types: {', '.join(UNSUPPORTED_ITEM_TYPES)}", - fab_constant.ERROR_UNSUPPORTED_ITEM_TYPE, - ) - if t not in SEARCHABLE_ITEM_TYPES: - raise FabricCLIError( - f"Unknown item type: '{t}'. Use tab completion to see valid types.", - fab_constant.ERROR_INVALID_ITEM_TYPE, - ) + request: dict[str, Any] = {"search": args.query} + + # 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 + types = _parse_type_param(args) + if types: filter_parts = [f"Type eq '{t}'" for t in types] request["filter"] = " or ".join(filter_parts) return request -def _handle_response(args: Namespace, response) -> None: - """Handle the API response, including error cases.""" - # Check for error responses +def _parse_type_param(args: Namespace) -> list[str]: + """Extract and validate item types from -P params. + + Supports: -P type=Report or -P type=Report,Lakehouse + """ + params = getattr(args, "params", None) + if not params: + return [] + + # params is a list from argparse nargs="*", e.g. ["type=Report,Lakehouse"] + type_value = None + for param in params: + if "=" not in param: + raise FabricCLIError( + f"Invalid parameter format: '{param}'. Expected key=value.", + fab_constant.ERROR_INVALID_INPUT, + ) + key, value = param.split("=", 1) + if key.lower() == "type": + type_value = value + else: + raise FabricCLIError( + f"Unknown parameter: '{key}'. Supported: type", + fab_constant.ERROR_INVALID_INPUT, + ) + + if not type_value: + return [] + + types = [t.strip() for t in type_value.split(",") if t.strip()] + + # Validate types + for t in types: + if t in UNSUPPORTED_ITEM_TYPES: + raise FabricCLIError( + f"Item type '{t}' is not searchable via catalog search API. " + f"Unsupported types: {', '.join(UNSUPPORTED_ITEM_TYPES)}", + fab_constant.ERROR_UNSUPPORTED_ITEM_TYPE, + ) + if t not in SEARCHABLE_ITEM_TYPES: + raise FabricCLIError( + f"Unknown item type: '{t}'. Valid types: {', '.join(SEARCHABLE_ITEM_TYPES)}", + fab_constant.ERROR_INVALID_ITEM_TYPE, + ) + + return types + + +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) @@ -162,34 +227,12 @@ def _handle_response(args: Namespace, response) -> None: error_code, ) - _display_results(args, response) - -def _display_results(args: Namespace, response) -> None: - """Format and display search results.""" - results = json.loads(response.text) - items = results.get("value", []) - continuation_token = results.get("continuationToken") - - if not items: - utils_ui.print_grey("No items found.") - return - - # Add result count info - count = len(items) - has_more = continuation_token is not None - count_msg = f"{count} item(s) found" + (" (more available)" if has_more else "") - utils_ui.print_grey("") # Blank line after "Searching..." - utils_ui.print_grey(count_msg) - utils_ui.print_grey("") # Blank line separator - - # Check if detailed output is requested +def _display_items(args: Namespace, items: list[dict]) -> None: + """Format and display search result items.""" detailed = getattr(args, "long", False) if detailed: - # Detailed output: vertical key-value list with all fields - # Use snake_case keys for proper Title Case formatting by fab_ui - # Only include keys with non-empty values display_items = [] for item in items: entry = { @@ -199,16 +242,13 @@ def _display_results(args: Namespace, response) -> None: "workspace": item.get("workspaceName"), "workspace_id": item.get("workspaceId"), } - # Only add description if it has a value if item.get("description"): entry["description"] = item.get("description") display_items.append(entry) utils_ui.print_output_format(args, data=display_items, show_key_value_list=True) else: - # Default output: compact table view - # Check if any items have descriptions has_descriptions = any(item.get("description") for item in items) - + display_items = [] for item in items: entry = { @@ -216,13 +256,7 @@ def _display_results(args: Namespace, response) -> None: "type": item.get("type"), "workspace": item.get("workspaceName"), } - # Only include description column if any item has a description if has_descriptions: entry["description"] = item.get("description") or "" display_items.append(entry) utils_ui.print_output_format(args, data=display_items, show_headers=True) - - # Output continuation token if more results available - if continuation_token: - utils_ui.print_grey("") - utils_ui.print_grey(f"To get more results, use: --next-token \"{continuation_token}\"") diff --git a/src/fabric_cli/parsers/fab_find_parser.py b/src/fabric_cli/parsers/fab_find_parser.py index 7beca440..84e752f3 100644 --- a/src/fabric_cli/parsers/fab_find_parser.py +++ b/src/fabric_cli/parsers/fab_find_parser.py @@ -3,11 +3,9 @@ """Parser for the find command.""" -import argparse from argparse import Namespace, _SubParsersAction from fabric_cli.commands.find import fab_find as find -from fabric_cli.core import fab_constant from fabric_cli.utils import fab_error_parser as utils_error_parser from fabric_cli.utils import fab_ui as utils_ui @@ -21,30 +19,19 @@ } -def _max_items_type(value: str) -> int: - """Validate --max-items is between 1 and 1000.""" - try: - ivalue = int(value) - except ValueError: - raise argparse.ArgumentTypeError(f"invalid int value: '{value}'") - if ivalue < 1 or ivalue > 1000: - raise argparse.ArgumentTypeError(f"must be between 1 and 1000, got {ivalue}") - return ivalue - - def register_parser(subparsers: _SubParsersAction) -> None: """Register the find command parser.""" examples = [ "# search for items by name or description", "$ find 'sales report'\n", "# search for lakehouses only", - "$ find 'data' --type Lakehouse\n", + "$ find 'data' -P type=Lakehouse\n", "# search for multiple item types", - "$ find 'dashboard' --type Report SemanticModel\n", + "$ find 'dashboard' -P type=Report,SemanticModel\n", "# show detailed output with IDs", "$ find 'sales' -l\n", "# combine filters", - "$ find 'finance' --type Warehouse Lakehouse --max-items 20", + "$ find 'finance' -P type=Warehouse,Lakehouse -l", ] parser = subparsers.add_parser( @@ -56,25 +43,15 @@ 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( - "--type", - nargs="+", - metavar="TYPE", - help="Filter by item type(s). Examples: Report, Lakehouse, Warehouse. Use for full list.", - ) - # Add tab-completion for item types - type_arg.completer = find.complete_item_types - parser.add_argument( - "--max-items", - dest="limit", - metavar="N", - type=_max_items_type, - default=50, - help="Maximum number of results to return (1-1000, default: 50)", + "-P", + "--params", + required=False, + metavar="", + nargs="*", + help="Parameters in key=value format. Supported: type=[,...]", ) parser.add_argument( "-l", @@ -82,12 +59,6 @@ def register_parser(subparsers: _SubParsersAction) -> None: action="store_true", help="Show detailed output. Optional", ) - parser.add_argument( - "--next-token", - dest="continue_token", - metavar="TOKEN", - help="Continuation token from previous search to get next page of results", - ) parser.usage = f"{utils_error_parser.get_usage_prog(parser)}" parser.set_defaults(func=find.find_command) From 3c60e96b8db80cf52f869587fc80898212acfb6b Mon Sep 17 00:00:00 2001 From: Nadav Schachter Date: Tue, 24 Feb 2026 16:26:52 +0200 Subject: [PATCH 23/29] test(find): update tests for pagination and -P params refactor Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/test_commands/find/test_find.py | 354 +++++++++++++------------- 1 file changed, 183 insertions(+), 171 deletions(-) diff --git a/tests/test_commands/find/test_find.py b/tests/test_commands/find/test_find.py index 0bd06add..972a20c8 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 import fab_constant from fabric_cli.core.fab_exceptions import FabricCLIError @@ -61,75 +62,109 @@ 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) + def test_basic_query_interactive(self): + """Test basic search query in interactive mode.""" + args = Namespace(query="sales report", params=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 - 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) + def test_basic_query_commandline(self): + """Test basic search query in command-line mode.""" + args = Namespace(query="sales report", params=None) + payload = fab_find._build_search_payload(args, is_interactive=False) - assert payload["search"] == "data" - assert payload["pageSize"] == 10 + 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 (as list from nargs='+').""" - args = Namespace(query="report", type=["Report"], limit=None) - payload = fab_find._build_search_payload(args) + """Test search with single type filter via -P.""" + args = Namespace(query="report", params=["type=Report"]) + 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 (as list from nargs='+').""" - args = Namespace(query="data", type=["Lakehouse", "Warehouse"], limit=None) - payload = fab_find._build_search_payload(args) + """Test search with multiple type filters via -P.""" + args = Namespace(query="data", params=["type=Lakehouse,Warehouse"]) + payload = fab_find._build_search_payload(args, is_interactive=False) assert payload["search"] == "data" assert "Type eq 'Lakehouse'" in payload["filter"] assert "Type eq 'Warehouse'" in payload["filter"] assert " or " in payload["filter"] - def test_query_with_all_options(self): - """Test search with all options.""" - args = Namespace(query="monthly", type=["Report", "Notebook"], limit=25) - payload = fab_find._build_search_payload(args) - assert payload["search"] == "monthly" - assert payload["pageSize"] == 25 - assert "Type eq 'Report'" in payload["filter"] - assert "Type eq 'Notebook'" in payload["filter"] +class 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) == [] -class TestDisplayResults: - """Tests for _display_results function.""" + def test_empty_params(self): + """Test with empty params list.""" + args = Namespace(params=[]) + assert fab_find._parse_type_param(args) == [] + + def test_single_type(self): + """Test single type value.""" + args = Namespace(params=["type=Report"]) + assert fab_find._parse_type_param(args) == ["Report"] + + def test_multiple_types_comma_separated(self): + """Test comma-separated types.""" + args = Namespace(params=["type=Report,Lakehouse"]) + result = fab_find._parse_type_param(args) + assert result == ["Report", "Lakehouse"] + + 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 "Unknown parameter" in str(exc_info.value) + + def test_unsupported_type_raises_error(self): + """Test error for unsupported item types like Dashboard.""" + 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 "not 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 "Unknown item type" in str(exc_info.value) + + +class TestDisplayItems: + """Tests for _display_items 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.""" + def test_display_items_table(self, mock_print_format): + """Test displaying items in table mode.""" args = Namespace(long=False, output_format="text") - response = MagicMock() - response.text = json.dumps(SAMPLE_RESPONSE_WITH_RESULTS) - - fab_find._display_results(args, response) + items = SAMPLE_RESPONSE_WITH_RESULTS["value"] - # Should print count message - mock_print_grey.assert_called() - # Find the count message in the call list - count_calls = [c[0][0] for c in mock_print_grey.call_args_list if "item(s) found" in c[0][0]] - assert len(count_calls) == 1 - count_call = count_calls[0] - assert "2 item(s) found" in count_call - assert "(more available)" in count_call # Has continuation token + fab_find._display_items(args, items) - # Should call print_output_format with display items mock_print_format.assert_called_once() display_items = mock_print_format.call_args.kwargs["data"] assert len(display_items) == 2 @@ -138,36 +173,18 @@ def test_display_results_with_items(self, mock_print_format, mock_print_grey): assert display_items[0]["workspace"] == "Sales Department" assert display_items[0]["description"] == "Consolidated revenue report for the current fiscal year." - @patch("fabric_cli.utils.fab_ui.print_grey") @patch("fabric_cli.utils.fab_ui.print_output_format") - def test_display_results_empty(self, mock_print_format, mock_print_grey): - """Test displaying empty results.""" - args = Namespace(long=False, output_format="text") - response = MagicMock() - response.text = json.dumps(SAMPLE_RESPONSE_EMPTY) - - fab_find._display_results(args, response) - - # Should print "No items found" - mock_print_grey.assert_called_with("No items found.") - mock_print_format.assert_not_called() - - @patch("fabric_cli.utils.fab_ui.print_grey") - @patch("fabric_cli.utils.fab_ui.print_output_format") - def test_display_results_detailed(self, mock_print_format, mock_print_grey): - """Test displaying results with long flag.""" + def test_display_items_detailed(self, mock_print_format): + """Test displaying items with long flag.""" args = Namespace(long=True, output_format="text") - response = MagicMock() - response.text = json.dumps(SAMPLE_RESPONSE_SINGLE) + items = SAMPLE_RESPONSE_SINGLE["value"] - fab_find._display_results(args, response) + fab_find._display_items(args, items) - # Should call print_output_format with detailed items mock_print_format.assert_called_once() display_items = mock_print_format.call_args.kwargs["data"] assert len(display_items) == 1 - # Detailed view should include id and workspace_id (snake_case) item = display_items[0] assert item["name"] == "Data Analysis" assert item["type"] == "Notebook" @@ -176,142 +193,137 @@ def test_display_results_detailed(self, mock_print_format, mock_print_grey): assert item["id"] == "abc12345-1234-5678-9abc-def012345678" assert item["workspace_id"] == "workspace-id-123" - @patch("fabric_cli.utils.fab_ui.print_grey") - @patch("fabric_cli.utils.fab_ui.print_output_format") - def test_display_results_no_continuation_token(self, mock_print_format, mock_print_grey): - """Test count message without continuation token.""" - args = Namespace(long=False, output_format="text") - response = MagicMock() - response.text = json.dumps(SAMPLE_RESPONSE_SINGLE) - fab_find._display_results(args, response) +class TestRaiseOnError: + """Tests for _raise_on_error function.""" - # Should not show "(more available)" - # Find the count message in the call list - count_calls = [c[0][0] for c in mock_print_grey.call_args_list if "item(s) found" in c[0][0]] - assert len(count_calls) == 1 - count_call = count_calls[0] - assert "1 item(s) found" in count_call - assert "(more available)" not in count_call + 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" + }) -class TestTypeValidation: - """Tests for type validation errors.""" + with pytest.raises(FabricCLIError) as exc_info: + fab_find._raise_on_error(response) - def test_unsupported_type_raises_error(self): - """Test error for unsupported item types like Dashboard.""" - args = Namespace(query="test", type=["Dashboard"], limit=None) + 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._build_search_payload(args) + fab_find._raise_on_error(response) - assert "Dashboard" in str(exc_info.value) - assert "not searchable" in str(exc_info.value) + assert "Catalog search failed" 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) +class TestFindCommandline: + """Tests for _find_commandline function.""" - assert "InvalidType" in str(exc_info.value) - assert "Unknown" in str(exc_info.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_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 - def test_valid_type_builds_filter(self): - """Test valid type builds correct filter.""" - args = Namespace(query="test", type=["Report"], limit=None) - payload = fab_find._build_search_payload(args) - assert payload["filter"] == "Type eq 'Report'" + args = Namespace(long=False, output_format="text") + payload = {"search": "test", "pageSize": 1000} - def test_multiple_types_build_or_filter(self): - """Test multiple types build OR filter.""" - args = Namespace(query="test", type=["Report", "Lakehouse"], limit=None) - payload = fab_find._build_search_payload(args) - assert "Type eq 'Report'" in payload["filter"] - assert "Type eq 'Lakehouse'" in payload["filter"] - assert " or " in payload["filter"] + fab_find._find_commandline(args, payload) - def test_searchable_types_list(self): - """Test SEARCHABLE_ITEM_TYPES excludes unsupported types.""" - assert "Dashboard" not in fab_find.SEARCHABLE_ITEM_TYPES - assert "Dataflow" in fab_find.SEARCHABLE_ITEM_TYPES - assert "Report" in fab_find.SEARCHABLE_ITEM_TYPES - assert "Lakehouse" in fab_find.SEARCHABLE_ITEM_TYPES + 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 -class TestCompleteItemTypes: - """Tests for the item type completer.""" + args = Namespace(long=False, output_format="text") + payload = {"search": "nothing", "pageSize": 1000} - def test_complete_with_prefix(self): - """Test completion with a prefix.""" - result = fab_find.complete_item_types("Lake") - assert "Lakehouse" in result + fab_find._find_commandline(args, payload) - def test_complete_case_insensitive(self): - """Test completion is case-insensitive.""" - result = fab_find.complete_item_types("report") - assert "Report" in result + mock_print_grey.assert_called_with("No items found.") - def test_complete_multiple_matches(self): - """Test completion returns multiple matches.""" - result = fab_find.complete_item_types("Data") - assert "Datamart" in result - assert "DataPipeline" in result - def test_complete_excludes_unsupported_types(self): - """Test completion excludes unsupported types like Dashboard.""" - result = fab_find.complete_item_types("Da") - assert "Dashboard" not in result - assert "Dataflow" in result - assert "Datamart" in result +class TestFindInteractive: + """Tests for _find_interactive function.""" - 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 + @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) -class TestHandleResponse: - """Tests for _handle_response function.""" + mock_search.side_effect = [page1, page2] - @patch("fabric_cli.commands.find.fab_find._display_results") - def test_success_response(self, mock_display): - """Test successful response handling.""" - args = Namespace(long=False) - response = MagicMock() - response.status_code = 200 - response.text = json.dumps(SAMPLE_RESPONSE_WITH_RESULTS) + args = Namespace(long=False, output_format="text") + payload = {"search": "sales", "pageSize": 50} - fab_find._handle_response(args, response) + fab_find._find_interactive(args, payload) - mock_display.assert_called_once_with(args, response) + 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)") - def test_error_response_raises_fabric_cli_error(self): - """Test error response raises FabricCLIError.""" - args = Namespace(long=False) + @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 = 403 - response.text = json.dumps({ - "errorCode": "InsufficientScopes", - "message": "Missing required scope: Catalog.Read.All" - }) + response.status_code = 200 + response.text = json.dumps(SAMPLE_RESPONSE_WITH_RESULTS) + mock_search.return_value = response - with pytest.raises(FabricCLIError) as exc_info: - fab_find._handle_response(args, response) + args = Namespace(long=False, output_format="text") + payload = {"search": "sales", "pageSize": 50} - assert "Catalog search failed" in str(exc_info.value) - assert "Missing required scope" in str(exc_info.value) + fab_find._find_interactive(args, payload) - def test_error_response_non_json(self): - """Test error response with non-JSON body.""" - args = Namespace(long=False) - response = MagicMock() - response.status_code = 500 - response.text = "Internal Server Error" + # Should only fetch one page (stopped by Ctrl+C) + assert mock_search.call_count == 1 + assert mock_print_format.call_count == 1 - with pytest.raises(FabricCLIError) as exc_info: - fab_find._handle_response(args, response) - assert "Catalog search failed" in str(exc_info.value) +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 From 88cf988f606608679ae4c3f11ed78f8058d533da Mon Sep 17 00:00:00 2001 From: Nadav Schachter Date: Tue, 3 Mar 2026 23:56:25 +0200 Subject: [PATCH 24/29] fix(find): make -P type filter case-insensitive --- issue-172.md | 258 ++++++++++++++++++++++ src/fabric_cli/commands/find/fab_find.py | 112 +++++++--- src/fabric_cli/parsers/fab_find_parser.py | 26 ++- tests/test_commands/find/test_find.py | 119 ++++++++-- 4 files changed, 461 insertions(+), 54 deletions(-) create mode 100644 issue-172.md diff --git a/issue-172.md b/issue-172.md new file mode 100644 index 00000000..db1ebf17 --- /dev/null +++ b/issue-172.md @@ -0,0 +1,258 @@ +### Use Case / Problem + +Currently, there's no way to search for Fabric items across workspaces from the CLI. +Users must either: +- Navigate to each workspace individually with `ls` +- Use the Fabric portal's OneLake catalog UI +- Make direct API calls + +This creates friction when users need to quickly locate items by name or description across their tenant. + +### Proposed Solution + +Add a new `find` command to search across all accessible workspaces. + + +### Command Syntax + +``` +# Basic search +fab find "sales report" + +# Filter by item type +fab find "revenue" -P type=Lakehouse + +# Multiple types +fab find "monthly" -P type=Report,Warehouse + +# Detailed view (shows IDs for scripting) +fab find "sales" -l + +# Combine filters +fab find "finance" -P type=Warehouse,Lakehouse -l +``` + +### Flags + +| Flag | Description | +| --------------- | ------------------------------------------------------------------------------ | +| `-P`/`--params` | Parameters in key=value format. Supported: `type=[,...]` | +| `-l`/`--long` | Show detailed output with IDs | + +### Search Matching + +The search query matches against any of these fields: + +- `displayName` - Item name +- `workspaceName` - Workspace containing the item +- `description` - Item description + +### Default Output (interactive mode) +``` +fab > find 'sales report' +Searching catalog for 'sales report'... + +50 item(s) found (more available) + +name type workspace description +─────────────── ───────── ───────────────── ───────────────────────────────── +Sales Report Q1 Report Finance Reports Quarterly sales analysis for Q1 +Sales Report Q2 Report Finance Reports Monthly sales summary +... + +Press any key to continue... (Ctrl+C to stop) + +34 item(s) found + +name type workspace description +─────────────── ───────── ───────────────── ───────────────────────────────── +Sales Data Lakehouse Analytics Team Raw sales data lakehouse +... + +84 total item(s) +``` + + + +### Long Output (`-l`/`--long`) + +``` +Searching catalog for 'sales report'... + +3 item(s) found + +Name: Sales Report Q1 +ID: 0acd697c-1550-43cd-b998-91bfb12347c6 +Type: Report +Workspace: Finance Reports +Workspace ID: 18cd155c-7850-15cd-a998-91bfb12347aa +Description: Quarterly sales analysis for Q1 + +Name: Sales Report Q2 +ID: 1bde708d-2661-54de-c009-02cgc23458d7 +Type: Report +Workspace: Finance Reports +Workspace ID: 29de266d-8961-26de-b009-02cgc23458bb +``` + +Note: Empty fields (e.g., Description) are hidden for cleaner output. + + + +Users can then reference items using the standard CLI path format: + +``` +fab get "Finance Reports.Workspace/Sales Report Q1.Report" +``` + + + +### Output Format Support + +The command supports the global `--output_format` flag: + +- `--output_format text` (default): Table or key-value output +- `--output_format json`: JSON output for scripting + +### Error Handling + +The command uses structured errors via `FabricCLIError`: + +| Error | Code | Message | +| ---------------- | ----------------------------- | --------------------------------------------------------------- | +| Unsupported type | `ERROR_UNSUPPORTED_ITEM_TYPE` | "Item type 'Dashboard' is not searchable via catalog search API" | +| Unknown type | `ERROR_INVALID_ITEM_TYPE` | "Unknown item type: 'FakeType'. Valid types: ..." | +| Invalid param | `ERROR_INVALID_INPUT` | "Invalid parameter format: 'foo'. Expected key=value." | +| Unknown param | `ERROR_INVALID_INPUT` | "Unknown parameter: 'foo'. Supported: type" | +| API failure | (from response) | "Catalog search failed: {error message}" | +| Empty results | (info) | "No items found." | + +### Pagination + +Pagination is handled automatically based on CLI mode: + +- **Interactive mode**: Fetches 50 items per page. After each page, if more results are available, prompts "Press any key to continue... (Ctrl+C to stop)". Displays a running total at the end. +- **Command-line mode**: Fetches up to 1,000 items in a single request. All results are displayed at once. + +### Alternatives Considered + +- **`ls` with grep**: Requires knowing the workspace, doesn't search descriptions +- **Admin APIs**: Requires admin permissions, overkill for personal discovery +- **Portal search**: Not scriptable, breaks CLI-first workflows + +### Impact Assessment + +- [x] This would help me personally +- [x] This would help my team/organization +- [x] This would help the broader fabric-cli community +- [x] This aligns with Microsoft Fabric roadmap items + +### Implementation Attestation + +- [x] I understand this feature should maintain backward compatibility with existing commands +- [x] I confirm this feature request does not introduce performance regressions for existing workflows +- [x] I acknowledge that new features must follow fabric-cli's established patterns and conventions + +### Implementation Notes + +- Uses Catalog Search API (`POST /v1/catalog/search`) +- Type filtering via `-P type=Report,Lakehouse` using key=value param pattern (consistent with `-P` usage in `mkdir`) +- Interactive mode: pages 50 at a time with continuation tokens behind the scenes +- Command-line mode: single request with `pageSize=1000` +- The API currently does not support searching: 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'. +- Uses `print_output_format()` for output format support +- Uses `show_key_value_list=True` for `-l`/`--long` vertical layout +- Structured error handling with `FabricCLIError` and existing error codes + +--- + +### Comment: Design update — pagination and type filter refactor + +Updated the implementation based on review feedback and alignment with existing CLI patterns: + +#### Removed flags +- `--type` — replaced by `-P type=[,...]` (consistent with `-P` key=value pattern used in `mkdir`) +- `--max-items` — removed; pagination is now automatic +- `--next-token` — removed; continuation tokens are handled behind the scenes + +#### New pagination behavior + +**Interactive mode**: Fetches 50 items per page. After each page, if more results exist, prompts: +``` +Press any key to continue... (Ctrl+C to stop) +``` +Uses Ctrl+C for cancellation, consistent with the existing CLI convention (`fab_auth.py`, `fab_interactive.py`, `main.py` all use `KeyboardInterrupt`). Displays a running total at the end. + +**Command-line mode**: Fetches up to 1,000 items in a single request (`pageSize=1000`). All results displayed at once — no pagination needed. + +#### Updated command syntax +```bash +# Basic search +fab find 'sales report' + +# Filter by type (using -P) +fab find 'data' -P type=Lakehouse + +# Multiple types +fab find 'dashboard' -P type=Report,SemanticModel + +# Detailed output +fab find 'sales' -l + +# Combined +fab find 'finance' -P type=Warehouse,Lakehouse -l +``` + +#### Updated flags + +| Flag | Description | +| ----------------- | ---------------------------------------------------------------------------- | +| `-P`/`--params` | Parameters in key=value format. Supported: `type=[,...]` | +| `-l`/`--long` | Show detailed output with IDs | + +The issue body above has been updated to reflect these changes. + +--- + +### Comment: Bracket syntax for `-P` lists and `-q` JMESPath support + +Two additions to the `find` command: + +#### 1. Bracket syntax for `-P` type lists + +Multiple values for a parameter can now use bracket notation: + +```bash +# Single type (unchanged) +fab find 'data' -P type=Lakehouse + +# Multiple types — new bracket syntax +fab find 'data' -P type=[Lakehouse,Notebook] + +# Legacy comma syntax still works +fab find 'data' -P type=Lakehouse,Notebook +``` + +Filter generation: +- Single value: `Type eq 'Lakehouse'` +- Multiple values: `(Type eq 'Lakehouse' or Type eq 'Notebook')` +- Multi-value expressions are wrapped in parentheses for correct precedence when additional filter fields are added later + +#### 2. `-q`/`--query` JMESPath client-side filtering + +Consistent with `ls`, `acls`, `api`, and `fs` commands, `find` now supports JMESPath expressions for client-side filtering: + +```bash +# Filter results to only Reports +fab find 'sales' -q "[?type=='Report']" + +# Project specific fields +fab find 'data' -q "[].{name: name, workspace: workspace}" +``` + +JMESPath is applied after API results are received, per-page in interactive mode. + +#### Internal change: positional arg renamed + +The positional search text argument's internal `dest` was renamed from `query` to `search_text` to avoid collision with `-q`/`--query`. The CLI syntax is unchanged — `fab find 'search text'` still works. diff --git a/src/fabric_cli/commands/find/fab_find.py b/src/fabric_cli/commands/find/fab_find.py index d20edf5a..d2a8660b 100644 --- a/src/fabric_cli/commands/find/fab_find.py +++ b/src/fabric_cli/commands/find/fab_find.py @@ -11,7 +11,9 @@ 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) @@ -74,10 +76,13 @@ @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.query}'...") + utils_ui.print_grey(f"Searching catalog for '{args.search_text}'...") if is_interactive: _find_interactive(args, payload) @@ -149,66 +154,110 @@ def _find_commandline(args: Namespace, payload: dict[str, Any]) -> None: 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.query} + 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 - types = _parse_type_param(args) - if types: - filter_parts = [f"Type eq '{t}'" for t in types] - request["filter"] = " or ".join(filter_parts) + 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) -> list[str]: +def _parse_type_param(args: Namespace) -> dict[str, Any] | None: """Extract and validate item types from -P params. - Supports: -P type=Report or -P type=Report,Lakehouse + 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 [] + return None - # params is a list from argparse nargs="*", e.g. ["type=Report,Lakehouse"] + # params is a list from argparse nargs="*", e.g. ["type=[Report,Lakehouse]"] type_value = None + operator = "eq" for param in params: - if "=" not in param: - raise FabricCLIError( - f"Invalid parameter format: '{param}'. Expected key=value.", - fab_constant.ERROR_INVALID_INPUT, - ) - key, value = param.split("=", 1) - if key.lower() == "type": - type_value = value + if "!=" in param: + key, value = param.split("!=", 1) + if key.lower() == "type": + type_value = value + operator = "ne" + else: + raise FabricCLIError( + f"Unknown parameter: '{key}'. 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"Unknown parameter: '{key}'. Supported: type", + fab_constant.ERROR_INVALID_INPUT, + ) else: raise FabricCLIError( - f"Unknown parameter: '{key}'. Supported: type", + f"Invalid parameter format: '{param}'. Expected key=value or key!=value.", fab_constant.ERROR_INVALID_INPUT, ) if not type_value: - return [] + return None - types = [t.strip() for t in type_value.split(",") if t.strip()] + # 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 types + # 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: - if t in UNSUPPORTED_ITEM_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"Item type '{t}' is not searchable via catalog search API. " + f"Item type '{canonical}' is not searchable via catalog search API. " f"Unsupported types: {', '.join(UNSUPPORTED_ITEM_TYPES)}", fab_constant.ERROR_UNSUPPORTED_ITEM_TYPE, ) - if t not in SEARCHABLE_ITEM_TYPES: + if t_lower not in all_types_lower: raise FabricCLIError( - f"Unknown item type: '{t}'. Valid types: {', '.join(SEARCHABLE_ITEM_TYPES)}", + f"Unknown item type: '{t}'. Valid types: {', '.join(ALL_ITEM_TYPES)}", fab_constant.ERROR_INVALID_ITEM_TYPE, ) + normalized.append(all_types_lower[t_lower]) - return types + return {"operator": operator, "values": normalized} def _raise_on_error(response) -> None: @@ -245,7 +294,6 @@ def _display_items(args: Namespace, items: list[dict]) -> None: 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: has_descriptions = any(item.get("description") for item in items) @@ -259,4 +307,12 @@ def _display_items(args: Namespace, items: list[dict]) -> None: if has_descriptions: entry["description"] = item.get("description") or "" display_items.append(entry) + + # 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) diff --git a/src/fabric_cli/parsers/fab_find_parser.py b/src/fabric_cli/parsers/fab_find_parser.py index 84e752f3..c9482619 100644 --- a/src/fabric_cli/parsers/fab_find_parser.py +++ b/src/fabric_cli/parsers/fab_find_parser.py @@ -26,12 +26,20 @@ def register_parser(subparsers: _SubParsersAction) -> None: "$ find 'sales report'\n", "# search for lakehouses only", "$ find 'data' -P type=Lakehouse\n", - "# search for multiple item types", - "$ find 'dashboard' -P type=Report,SemanticModel\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", + "$ 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( @@ -42,7 +50,8 @@ def register_parser(subparsers: _SubParsersAction) -> None: ) parser.add_argument( - "query", + "search_text", + metavar="query", help="Search text (matches display name, description, and workspace name)", ) parser.add_argument( @@ -51,7 +60,7 @@ def register_parser(subparsers: _SubParsersAction) -> None: required=False, metavar="", nargs="*", - help="Parameters in key=value format. Supported: type=[,...]", + 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", @@ -59,6 +68,13 @@ def register_parser(subparsers: _SubParsersAction) -> None: 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) diff --git a/tests/test_commands/find/test_find.py b/tests/test_commands/find/test_find.py index 972a20c8..920a8378 100644 --- a/tests/test_commands/find/test_find.py +++ b/tests/test_commands/find/test_find.py @@ -64,7 +64,7 @@ class TestBuildSearchPayload: def test_basic_query_interactive(self): """Test basic search query in interactive mode.""" - args = Namespace(query="sales report", params=None) + 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" @@ -73,7 +73,7 @@ def test_basic_query_interactive(self): def test_basic_query_commandline(self): """Test basic search query in command-line mode.""" - args = Namespace(query="sales report", params=None) + 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" @@ -82,21 +82,41 @@ def test_basic_query_commandline(self): def test_query_with_single_type(self): """Test search with single type filter via -P.""" - args = Namespace(query="report", params=["type=Report"]) + 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.""" - args = Namespace(query="data", params=["type=Lakehouse,Warehouse"]) + """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 "Type eq 'Lakehouse'" in payload["filter"] - assert "Type eq 'Warehouse'" in payload["filter"] - assert " or " in payload["filter"] + 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: @@ -105,23 +125,48 @@ class TestParseTypeParam: def test_no_params(self): """Test with no params.""" args = Namespace(params=None) - assert fab_find._parse_type_param(args) == [] + 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) == [] + assert fab_find._parse_type_param(args) is None def test_single_type(self): """Test single type value.""" args = Namespace(params=["type=Report"]) - assert fab_find._parse_type_param(args) == ["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.""" + """Test comma-separated types (legacy syntax).""" args = Namespace(params=["type=Report,Lakehouse"]) result = fab_find._parse_type_param(args) - assert result == ["Report", "Lakehouse"] + 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.""" @@ -137,8 +182,15 @@ def test_unknown_param_raises_error(self): fab_find._parse_type_param(args) assert "Unknown parameter" in str(exc_info.value) - def test_unsupported_type_raises_error(self): - """Test error for unsupported item types like Dashboard.""" + 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 "Unknown 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) @@ -153,6 +205,14 @@ def test_unknown_type_raises_error(self): assert "InvalidType" in str(exc_info.value) assert "Unknown 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 "Unknown item type" in str(exc_info.value) + class TestDisplayItems: """Tests for _display_items function.""" @@ -160,7 +220,7 @@ class TestDisplayItems: @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") + args = Namespace(long=False, output_format="text", query=None) items = SAMPLE_RESPONSE_WITH_RESULTS["value"] fab_find._display_items(args, items) @@ -176,7 +236,7 @@ def test_display_items_table(self, mock_print_format): @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") + args = Namespace(long=True, output_format="text", query=None) items = SAMPLE_RESPONSE_SINGLE["value"] fab_find._display_items(args, items) @@ -193,6 +253,23 @@ def test_display_items_detailed(self, mock_print_format): 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.""" @@ -243,7 +320,7 @@ def test_displays_results(self, mock_search, mock_print_grey, mock_print_format) response.text = json.dumps(SAMPLE_RESPONSE_SINGLE) mock_search.return_value = response - args = Namespace(long=False, output_format="text") + args = Namespace(long=False, output_format="text", query=None) payload = {"search": "test", "pageSize": 1000} fab_find._find_commandline(args, payload) @@ -260,7 +337,7 @@ def test_empty_results(self, mock_search, mock_print_grey): response.text = json.dumps(SAMPLE_RESPONSE_EMPTY) mock_search.return_value = response - args = Namespace(long=False, output_format="text") + args = Namespace(long=False, output_format="text", query=None) payload = {"search": "nothing", "pageSize": 1000} fab_find._find_commandline(args, payload) @@ -288,7 +365,7 @@ def test_pages_through_results(self, mock_search, mock_print_grey, mock_print_fo mock_search.side_effect = [page1, page2] - args = Namespace(long=False, output_format="text") + args = Namespace(long=False, output_format="text", query=None) payload = {"search": "sales", "pageSize": 50} fab_find._find_interactive(args, payload) @@ -308,7 +385,7 @@ def test_ctrl_c_stops_pagination(self, mock_search, mock_print_grey, mock_print_ response.text = json.dumps(SAMPLE_RESPONSE_WITH_RESULTS) mock_search.return_value = response - args = Namespace(long=False, output_format="text") + args = Namespace(long=False, output_format="text", query=None) payload = {"search": "sales", "pageSize": 50} fab_find._find_interactive(args, payload) From e05058929f58890943ccd0241bb369f843d8fbab Mon Sep 17 00:00:00 2001 From: Nadav Schachter Date: Wed, 4 Mar 2026 00:09:40 +0200 Subject: [PATCH 25/29] fix(find): treat empty continuation token as end of results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Catalog Search API returns an empty string continuationToken on the last page instead of null/omitting it. This caused the interactive pagination loop to send an empty token on the next request, which the API treats as a fresh empty search — returning unrelated results from the entire tenant. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- 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 d2a8660b..cee4ef42 100644 --- a/src/fabric_cli/commands/find/fab_find.py +++ b/src/fabric_cli/commands/find/fab_find.py @@ -100,7 +100,7 @@ def _find_interactive(args: Namespace, payload: dict[str, Any]) -> None: results = json.loads(response.text) items = results.get("value", []) - continuation_token = results.get("continuationToken") + continuation_token = results.get("continuationToken") or None if not items and total_count == 0: utils_ui.print_grey("No items found.") From 458137f536900b344d23164d0a6beda737d7a543 Mon Sep 17 00:00:00 2001 From: Nadav Schachter Date: Wed, 4 Mar 2026 00:30:34 +0200 Subject: [PATCH 26/29] fix(find): truncate descriptions to fit terminal width Long descriptions caused the table separator line to wrap, appearing as a double separator. Descriptions are now truncated with ellipsis to keep the table within the terminal width. Full descriptions are still available via -l/--long mode. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/fabric_cli/commands/find/fab_find.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/fabric_cli/commands/find/fab_find.py b/src/fabric_cli/commands/find/fab_find.py index cee4ef42..9b97501a 100644 --- a/src/fabric_cli/commands/find/fab_find.py +++ b/src/fabric_cli/commands/find/fab_find.py @@ -4,6 +4,7 @@ """Find command for searching the Fabric catalog.""" import json +import shutil from argparse import Namespace from typing import Any @@ -308,6 +309,10 @@ def _display_items(args: Namespace, items: list[dict]) -> None: 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) @@ -316,3 +321,20 @@ def _display_items(args: Namespace, items: list[dict]) -> None: 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] + "…" From c143d169b50513684c5454a27117e7930f610463 Mon Sep 17 00:00:00 2001 From: Nadav Schachter Date: Wed, 4 Mar 2026 01:00:04 +0200 Subject: [PATCH 27/29] feat(find): paginate all results in command-line mode Command-line mode now fetches all pages instead of stopping at one page of 1000. Uses the same continuation token pattern as interactive mode with 'or None' guard against empty string tokens. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/fabric_cli/commands/find/fab_find.py | 27 ++++++++++++++++-------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/fabric_cli/commands/find/fab_find.py b/src/fabric_cli/commands/find/fab_find.py index 9b97501a..38b81802 100644 --- a/src/fabric_cli/commands/find/fab_find.py +++ b/src/fabric_cli/commands/find/fab_find.py @@ -101,7 +101,7 @@ def _find_interactive(args: Namespace, payload: dict[str, Any]) -> None: results = json.loads(response.text) items = results.get("value", []) - continuation_token = results.get("continuationToken") or None + continuation_token = results.get("continuationToken", "") or None if not items and total_count == 0: utils_ui.print_grey("No items found.") @@ -135,22 +135,31 @@ def _find_interactive(args: Namespace, payload: dict[str, Any]) -> None: def _find_commandline(args: Namespace, payload: dict[str, Any]) -> None: - """Fetch up to 1000 results in a single request and display.""" - response = catalog_api.catalog_search(args, payload) - _raise_on_error(response) + """Fetch all results across pages and display.""" + all_items: list[dict] = [] - results = json.loads(response.text) - items = results.get("value", []) + 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 items: + if not all_items: utils_ui.print_grey("No items found.") return utils_ui.print_grey("") - utils_ui.print_grey(f"{len(items)} item(s) found") + utils_ui.print_grey(f"{len(all_items)} item(s) found") utils_ui.print_grey("") - _display_items(args, items) + _display_items(args, all_items) def _build_search_payload(args: Namespace, is_interactive: bool) -> dict[str, Any]: From 320bfc64a5fad32021e57895d3130743a492bdde Mon Sep 17 00:00:00 2001 From: Nadav Schachter Date: Wed, 4 Mar 2026 16:18:57 +0200 Subject: [PATCH 28/29] fix(find): improve error messages per Microsoft Writing Style Guide - Use contractions and active voice for friendlier tone - Suggest close matches for unknown types instead of dumping all 43 - Remove redundant type list from unsupported type error Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- issue-172.md | 68 +++++++++++++++++++++--- src/fabric_cli/commands/find/fab_find.py | 14 ++--- tests/test_commands/find/test_find.py | 10 ++-- 3 files changed, 74 insertions(+), 18 deletions(-) diff --git a/issue-172.md b/issue-172.md index db1ebf17..637c697b 100644 --- a/issue-172.md +++ b/issue-172.md @@ -22,22 +22,30 @@ fab find "sales report" # Filter by item type fab find "revenue" -P type=Lakehouse -# Multiple types -fab find "monthly" -P type=Report,Warehouse +# Multiple types (bracket syntax) +fab find "monthly" -P type=[Report,Warehouse] + +# Exclude types +fab find "data" -P type!=Dashboard +fab find "data" -P type!=[Dashboard,Datamart] # Detailed view (shows IDs for scripting) fab find "sales" -l # Combine filters -fab find "finance" -P type=Warehouse,Lakehouse -l +fab find "finance" -P type=[Warehouse,Lakehouse] -l + +# JMESPath client-side filtering +fab find "sales" -q "[?type=='Lakehouse']" ``` ### Flags | Flag | Description | | --------------- | ------------------------------------------------------------------------------ | -| `-P`/`--params` | Parameters in key=value format. Supported: `type=[,...]` | +| `-P`/`--params` | Parameters in key=value format. Supported: `type=` (eq) and `type!=` (ne) | | `-l`/`--long` | Show detailed output with IDs | +| `-q`/`--query` | JMESPath expression for client-side filtering | ### Search Matching @@ -132,7 +140,7 @@ The command uses structured errors via `FabricCLIError`: Pagination is handled automatically based on CLI mode: - **Interactive mode**: Fetches 50 items per page. After each page, if more results are available, prompts "Press any key to continue... (Ctrl+C to stop)". Displays a running total at the end. -- **Command-line mode**: Fetches up to 1,000 items in a single request. All results are displayed at once. +- **Command-line mode**: Fetches all pages automatically (1,000 items per page). All results are accumulated and displayed as a single table. ### Alternatives Considered @@ -156,9 +164,11 @@ Pagination is handled automatically based on CLI mode: ### Implementation Notes - Uses Catalog Search API (`POST /v1/catalog/search`) -- Type filtering via `-P type=Report,Lakehouse` using key=value param pattern (consistent with `-P` usage in `mkdir`) +- Type filtering via `-P type=Report,Lakehouse` using key=value param pattern; supports negation (`type!=Dashboard`) and bracket syntax (`type=[Report,Lakehouse]`) +- Type names are case-insensitive (normalized to PascalCase internally) - Interactive mode: pages 50 at a time with continuation tokens behind the scenes -- Command-line mode: single request with `pageSize=1000` +- Command-line mode: fetches all pages automatically (1,000 per page) +- Descriptions truncated to terminal width in compact view; full text available via `-l` - The API currently does not support searching: 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'. - Uses `print_output_format()` for output format support @@ -256,3 +266,47 @@ JMESPath is applied after API results are received, per-page in interactive mode #### Internal change: positional arg renamed The positional search text argument's internal `dest` was renamed from `query` to `search_text` to avoid collision with `-q`/`--query`. The CLI syntax is unchanged — `fab find 'search text'` still works. + +--- + +### Comment: Type negation, case-insensitive matching, pagination fixes + +Several improvements to the `find` command: + +#### 1. Type negation with `!=` + +```bash +# Exclude a single type +fab find 'data' -P type!=Dashboard + +# Exclude multiple types +fab find 'data' -P type!=[Dashboard,Datamart] +``` + +Filter generation: +- Single negation: `Type ne 'Dashboard'` +- Multiple negation: `(Type ne 'Dashboard' and Type ne 'Datamart')` + +#### 2. Case-insensitive type matching + +Type names in `-P` are now case-insensitive. All of these work: + +```bash +fab find 'data' -P type=lakehouse +fab find 'data' -P type=LAKEHOUSE +fab find 'data' -P type=Lakehouse +``` + +Input is normalized to the canonical PascalCase before validation and filter building. + +#### 3. Command-line mode fetches all pages + +Command-line mode now paginates automatically across all pages instead of stopping at one page of 1000. Results are accumulated and displayed as a single table. + +#### 4. Description truncation + +Long descriptions are truncated with `…` to fit the terminal width, preventing the table separator from wrapping to a second line. Full descriptions are available via `-l`/`--long` mode. + +#### 5. Empty continuation token fix + +The API returns `""` (empty string) instead of `null` when there are no more pages. This was causing interactive mode to send an empty token on the next request, which the API treated as a fresh empty search. Fixed by treating empty string tokens as end-of-results. diff --git a/src/fabric_cli/commands/find/fab_find.py b/src/fabric_cli/commands/find/fab_find.py index 38b81802..027e99a0 100644 --- a/src/fabric_cli/commands/find/fab_find.py +++ b/src/fabric_cli/commands/find/fab_find.py @@ -218,7 +218,7 @@ def _parse_type_param(args: Namespace) -> dict[str, Any] | None: operator = "ne" else: raise FabricCLIError( - f"Unknown parameter: '{key}'. Supported: type", + f"'{key}' isn't a supported parameter. Supported: type", fab_constant.ERROR_INVALID_INPUT, ) elif "=" in param: @@ -228,12 +228,12 @@ def _parse_type_param(args: Namespace) -> dict[str, Any] | None: operator = "eq" else: raise FabricCLIError( - f"Unknown parameter: '{key}'. Supported: type", + f"'{key}' isn't a supported parameter. Supported: type", fab_constant.ERROR_INVALID_INPUT, ) else: raise FabricCLIError( - f"Invalid parameter format: '{param}'. Expected key=value or key!=value.", + f"Invalid parameter format: '{param}'. Use key=value or key!=value.", fab_constant.ERROR_INVALID_INPUT, ) @@ -256,13 +256,15 @@ def _parse_type_param(args: Namespace) -> dict[str, Any] | None: if t_lower in unsupported_lower and operator == "eq": canonical = all_types_lower.get(t_lower, t) raise FabricCLIError( - f"Item type '{canonical}' is not searchable via catalog search API. " - f"Unsupported types: {', '.join(UNSUPPORTED_ITEM_TYPES)}", + 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"Unknown item type: '{t}'. Valid types: {', '.join(ALL_ITEM_TYPES)}", + f"'{t}' isn't a recognized item type.{hint}", fab_constant.ERROR_INVALID_ITEM_TYPE, ) normalized.append(all_types_lower[t_lower]) diff --git a/tests/test_commands/find/test_find.py b/tests/test_commands/find/test_find.py index 920a8378..c752ae41 100644 --- a/tests/test_commands/find/test_find.py +++ b/tests/test_commands/find/test_find.py @@ -180,14 +180,14 @@ def test_unknown_param_raises_error(self): args = Namespace(params=["foo=bar"]) with pytest.raises(FabricCLIError) as exc_info: fab_find._parse_type_param(args) - assert "Unknown parameter" in str(exc_info.value) + 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 "Unknown parameter" in str(exc_info.value) + 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.""" @@ -195,7 +195,7 @@ def test_unsupported_type_eq_raises_error(self): with pytest.raises(FabricCLIError) as exc_info: fab_find._parse_type_param(args) assert "Dashboard" in str(exc_info.value) - assert "not searchable" 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.""" @@ -203,7 +203,7 @@ def test_unknown_type_raises_error(self): with pytest.raises(FabricCLIError) as exc_info: fab_find._parse_type_param(args) assert "InvalidType" in str(exc_info.value) - assert "Unknown item type" 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.""" @@ -211,7 +211,7 @@ def test_unknown_type_ne_raises_error(self): with pytest.raises(FabricCLIError) as exc_info: fab_find._parse_type_param(args) assert "InvalidType" in str(exc_info.value) - assert "Unknown item type" in str(exc_info.value) + assert "isn't a recognized item type" in str(exc_info.value) class TestDisplayItems: From 42b74322f7febea583caaba4fa2fd930dbbfc4fb Mon Sep 17 00:00:00 2001 From: Nadav Schachter <33177266+nadavs123@users.noreply.github.com> Date: Sun, 15 Mar 2026 17:11:14 +0200 Subject: [PATCH 29/29] Delete issue-172.md This is just a draft of the issue I opened --- issue-172.md | 312 --------------------------------------------------- 1 file changed, 312 deletions(-) delete mode 100644 issue-172.md diff --git a/issue-172.md b/issue-172.md deleted file mode 100644 index 637c697b..00000000 --- a/issue-172.md +++ /dev/null @@ -1,312 +0,0 @@ -### Use Case / Problem - -Currently, there's no way to search for Fabric items across workspaces from the CLI. -Users must either: -- Navigate to each workspace individually with `ls` -- Use the Fabric portal's OneLake catalog UI -- Make direct API calls - -This creates friction when users need to quickly locate items by name or description across their tenant. - -### Proposed Solution - -Add a new `find` command to search across all accessible workspaces. - - -### Command Syntax - -``` -# Basic search -fab find "sales report" - -# Filter by item type -fab find "revenue" -P type=Lakehouse - -# Multiple types (bracket syntax) -fab find "monthly" -P type=[Report,Warehouse] - -# Exclude types -fab find "data" -P type!=Dashboard -fab find "data" -P type!=[Dashboard,Datamart] - -# Detailed view (shows IDs for scripting) -fab find "sales" -l - -# Combine filters -fab find "finance" -P type=[Warehouse,Lakehouse] -l - -# JMESPath client-side filtering -fab find "sales" -q "[?type=='Lakehouse']" -``` - -### Flags - -| Flag | Description | -| --------------- | ------------------------------------------------------------------------------ | -| `-P`/`--params` | Parameters in key=value format. Supported: `type=` (eq) and `type!=` (ne) | -| `-l`/`--long` | Show detailed output with IDs | -| `-q`/`--query` | JMESPath expression for client-side filtering | - -### Search Matching - -The search query matches against any of these fields: - -- `displayName` - Item name -- `workspaceName` - Workspace containing the item -- `description` - Item description - -### Default Output (interactive mode) -``` -fab > find 'sales report' -Searching catalog for 'sales report'... - -50 item(s) found (more available) - -name type workspace description -─────────────── ───────── ───────────────── ───────────────────────────────── -Sales Report Q1 Report Finance Reports Quarterly sales analysis for Q1 -Sales Report Q2 Report Finance Reports Monthly sales summary -... - -Press any key to continue... (Ctrl+C to stop) - -34 item(s) found - -name type workspace description -─────────────── ───────── ───────────────── ───────────────────────────────── -Sales Data Lakehouse Analytics Team Raw sales data lakehouse -... - -84 total item(s) -``` - - - -### Long Output (`-l`/`--long`) - -``` -Searching catalog for 'sales report'... - -3 item(s) found - -Name: Sales Report Q1 -ID: 0acd697c-1550-43cd-b998-91bfb12347c6 -Type: Report -Workspace: Finance Reports -Workspace ID: 18cd155c-7850-15cd-a998-91bfb12347aa -Description: Quarterly sales analysis for Q1 - -Name: Sales Report Q2 -ID: 1bde708d-2661-54de-c009-02cgc23458d7 -Type: Report -Workspace: Finance Reports -Workspace ID: 29de266d-8961-26de-b009-02cgc23458bb -``` - -Note: Empty fields (e.g., Description) are hidden for cleaner output. - - - -Users can then reference items using the standard CLI path format: - -``` -fab get "Finance Reports.Workspace/Sales Report Q1.Report" -``` - - - -### Output Format Support - -The command supports the global `--output_format` flag: - -- `--output_format text` (default): Table or key-value output -- `--output_format json`: JSON output for scripting - -### Error Handling - -The command uses structured errors via `FabricCLIError`: - -| Error | Code | Message | -| ---------------- | ----------------------------- | --------------------------------------------------------------- | -| Unsupported type | `ERROR_UNSUPPORTED_ITEM_TYPE` | "Item type 'Dashboard' is not searchable via catalog search API" | -| Unknown type | `ERROR_INVALID_ITEM_TYPE` | "Unknown item type: 'FakeType'. Valid types: ..." | -| Invalid param | `ERROR_INVALID_INPUT` | "Invalid parameter format: 'foo'. Expected key=value." | -| Unknown param | `ERROR_INVALID_INPUT` | "Unknown parameter: 'foo'. Supported: type" | -| API failure | (from response) | "Catalog search failed: {error message}" | -| Empty results | (info) | "No items found." | - -### Pagination - -Pagination is handled automatically based on CLI mode: - -- **Interactive mode**: Fetches 50 items per page. After each page, if more results are available, prompts "Press any key to continue... (Ctrl+C to stop)". Displays a running total at the end. -- **Command-line mode**: Fetches all pages automatically (1,000 items per page). All results are accumulated and displayed as a single table. - -### Alternatives Considered - -- **`ls` with grep**: Requires knowing the workspace, doesn't search descriptions -- **Admin APIs**: Requires admin permissions, overkill for personal discovery -- **Portal search**: Not scriptable, breaks CLI-first workflows - -### Impact Assessment - -- [x] This would help me personally -- [x] This would help my team/organization -- [x] This would help the broader fabric-cli community -- [x] This aligns with Microsoft Fabric roadmap items - -### Implementation Attestation - -- [x] I understand this feature should maintain backward compatibility with existing commands -- [x] I confirm this feature request does not introduce performance regressions for existing workflows -- [x] I acknowledge that new features must follow fabric-cli's established patterns and conventions - -### Implementation Notes - -- Uses Catalog Search API (`POST /v1/catalog/search`) -- Type filtering via `-P type=Report,Lakehouse` using key=value param pattern; supports negation (`type!=Dashboard`) and bracket syntax (`type=[Report,Lakehouse]`) -- Type names are case-insensitive (normalized to PascalCase internally) -- Interactive mode: pages 50 at a time with continuation tokens behind the scenes -- Command-line mode: fetches all pages automatically (1,000 per page) -- Descriptions truncated to terminal width in compact view; full text available via `-l` -- The API currently does not support searching: 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'. -- Uses `print_output_format()` for output format support -- Uses `show_key_value_list=True` for `-l`/`--long` vertical layout -- Structured error handling with `FabricCLIError` and existing error codes - ---- - -### Comment: Design update — pagination and type filter refactor - -Updated the implementation based on review feedback and alignment with existing CLI patterns: - -#### Removed flags -- `--type` — replaced by `-P type=[,...]` (consistent with `-P` key=value pattern used in `mkdir`) -- `--max-items` — removed; pagination is now automatic -- `--next-token` — removed; continuation tokens are handled behind the scenes - -#### New pagination behavior - -**Interactive mode**: Fetches 50 items per page. After each page, if more results exist, prompts: -``` -Press any key to continue... (Ctrl+C to stop) -``` -Uses Ctrl+C for cancellation, consistent with the existing CLI convention (`fab_auth.py`, `fab_interactive.py`, `main.py` all use `KeyboardInterrupt`). Displays a running total at the end. - -**Command-line mode**: Fetches up to 1,000 items in a single request (`pageSize=1000`). All results displayed at once — no pagination needed. - -#### Updated command syntax -```bash -# Basic search -fab find 'sales report' - -# Filter by type (using -P) -fab find 'data' -P type=Lakehouse - -# Multiple types -fab find 'dashboard' -P type=Report,SemanticModel - -# Detailed output -fab find 'sales' -l - -# Combined -fab find 'finance' -P type=Warehouse,Lakehouse -l -``` - -#### Updated flags - -| Flag | Description | -| ----------------- | ---------------------------------------------------------------------------- | -| `-P`/`--params` | Parameters in key=value format. Supported: `type=[,...]` | -| `-l`/`--long` | Show detailed output with IDs | - -The issue body above has been updated to reflect these changes. - ---- - -### Comment: Bracket syntax for `-P` lists and `-q` JMESPath support - -Two additions to the `find` command: - -#### 1. Bracket syntax for `-P` type lists - -Multiple values for a parameter can now use bracket notation: - -```bash -# Single type (unchanged) -fab find 'data' -P type=Lakehouse - -# Multiple types — new bracket syntax -fab find 'data' -P type=[Lakehouse,Notebook] - -# Legacy comma syntax still works -fab find 'data' -P type=Lakehouse,Notebook -``` - -Filter generation: -- Single value: `Type eq 'Lakehouse'` -- Multiple values: `(Type eq 'Lakehouse' or Type eq 'Notebook')` -- Multi-value expressions are wrapped in parentheses for correct precedence when additional filter fields are added later - -#### 2. `-q`/`--query` JMESPath client-side filtering - -Consistent with `ls`, `acls`, `api`, and `fs` commands, `find` now supports JMESPath expressions for client-side filtering: - -```bash -# Filter results to only Reports -fab find 'sales' -q "[?type=='Report']" - -# Project specific fields -fab find 'data' -q "[].{name: name, workspace: workspace}" -``` - -JMESPath is applied after API results are received, per-page in interactive mode. - -#### Internal change: positional arg renamed - -The positional search text argument's internal `dest` was renamed from `query` to `search_text` to avoid collision with `-q`/`--query`. The CLI syntax is unchanged — `fab find 'search text'` still works. - ---- - -### Comment: Type negation, case-insensitive matching, pagination fixes - -Several improvements to the `find` command: - -#### 1. Type negation with `!=` - -```bash -# Exclude a single type -fab find 'data' -P type!=Dashboard - -# Exclude multiple types -fab find 'data' -P type!=[Dashboard,Datamart] -``` - -Filter generation: -- Single negation: `Type ne 'Dashboard'` -- Multiple negation: `(Type ne 'Dashboard' and Type ne 'Datamart')` - -#### 2. Case-insensitive type matching - -Type names in `-P` are now case-insensitive. All of these work: - -```bash -fab find 'data' -P type=lakehouse -fab find 'data' -P type=LAKEHOUSE -fab find 'data' -P type=Lakehouse -``` - -Input is normalized to the canonical PascalCase before validation and filter building. - -#### 3. Command-line mode fetches all pages - -Command-line mode now paginates automatically across all pages instead of stopping at one page of 1000. Results are accumulated and displayed as a single table. - -#### 4. Description truncation - -Long descriptions are truncated with `…` to fit the terminal width, preventing the table separator from wrapping to a second line. Full descriptions are available via `-l`/`--long` mode. - -#### 5. Empty continuation token fix - -The API returns `""` (empty string) instead of `null` when there are no more pages. This was causing interactive mode to send an empty token on the next request, which the API treated as a fresh empty search. Fixed by treating empty string tokens as end-of-results.