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/issue-172.md b/issue-172.md new file mode 100644 index 00000000..637c697b --- /dev/null +++ b/issue-172.md @@ -0,0 +1,312 @@ +### 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. diff --git a/src/fabric_cli/client/fab_api_catalog.py b/src/fabric_cli/client/fab_api_catalog.py new file mode 100644 index 00000000..c0ee2d3c --- /dev/null +++ b/src/fabric_cli/client/fab_api_catalog.py @@ -0,0 +1,47 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Catalog API client for searching Fabric items across workspaces. + +API Reference: POST https://api.fabric.microsoft.com/v1/catalog/search +Required Scope: Catalog.Read.All +""" + +from argparse import Namespace + +from fabric_cli.client import fab_api_client as fabric_api +from fabric_cli.client.fab_api_types import ApiResponse + + +def catalog_search(args: Namespace, payload: dict) -> ApiResponse: + """Search the Fabric catalog for items. + + https://learn.microsoft.com/en-us/rest/api/fabric/core/catalog/search + + Args: + args: Namespace with request configuration + payload: Dict with search request body: + - search (required): Text to search across displayName, description, workspaceName + - pageSize: Number of results per page + - continuationToken: Token for pagination + - filter: OData filter string, e.g., "Type eq 'Report' or Type eq 'Lakehouse'" + + Returns: + ApiResponse with search results containing: + - value: List of ItemCatalogEntry objects + - continuationToken: Token for next page (if more results exist) + + Note: + The following item types are NOT searchable via this API: + Dashboard + + Note: Dataflow Gen1 and Gen2 are currently not searchable; only Dataflow Gen2 + CI/CD items are returned (as type 'Dataflow'). + Scorecards are returned as type 'Report'. + """ + args.uri = "catalog/search" + args.method = "post" + # Use raw_response to avoid auto-pagination (we handle pagination in display) + args.raw_response = True + return fabric_api.do_request(args, json=payload) + diff --git a/src/fabric_cli/commands/find/__init__.py b/src/fabric_cli/commands/find/__init__.py new file mode 100644 index 00000000..59e481eb --- /dev/null +++ b/src/fabric_cli/commands/find/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. diff --git a/src/fabric_cli/commands/find/fab_find.py b/src/fabric_cli/commands/find/fab_find.py new file mode 100644 index 00000000..027e99a0 --- /dev/null +++ b/src/fabric_cli/commands/find/fab_find.py @@ -0,0 +1,351 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Find command for searching the Fabric catalog.""" + +import json +import shutil +from argparse import Namespace +from typing import Any + +from fabric_cli.client import fab_api_catalog as catalog_api +from fabric_cli.core import fab_constant +from fabric_cli.core.fab_decorators import handle_exceptions, set_command_context +from fabric_cli.core.fab_exceptions import FabricCLIError +from fabric_cli.utils import fab_jmespath as utils_jmespath +from fabric_cli.utils import fab_ui as utils_ui +from fabric_cli.utils import fab_util as utils + + +# All Fabric item types (from API spec, alphabetically sorted) +ALL_ITEM_TYPES = [ + "AnomalyDetector", + "ApacheAirflowJob", + "CopyJob", + "CosmosDBDatabase", + "Dashboard", + "Dataflow", + "Datamart", + "DataPipeline", + "DigitalTwinBuilder", + "DigitalTwinBuilderFlow", + "Environment", + "Eventhouse", + "EventSchemaSet", + "Eventstream", + "GraphModel", + "GraphQLApi", + "GraphQuerySet", + "KQLDashboard", + "KQLDatabase", + "KQLQueryset", + "Lakehouse", + "Map", + "MirroredAzureDatabricksCatalog", + "MirroredDatabase", + "MirroredWarehouse", + "MLExperiment", + "MLModel", + "MountedDataFactory", + "Notebook", + "Ontology", + "OperationsAgent", + "PaginatedReport", + "Reflex", + "Report", + "SemanticModel", + "SnowflakeDatabase", + "SparkJobDefinition", + "SQLDatabase", + "SQLEndpoint", + "UserDataFunction", + "VariableLibrary", + "Warehouse", + "WarehouseSnapshot", +] + +# Types that exist in Fabric but are NOT searchable via the Catalog Search API +UNSUPPORTED_ITEM_TYPES = [ + "Dashboard", +] + +# Types that ARE searchable (for validation) +SEARCHABLE_ITEM_TYPES = [t for t in ALL_ITEM_TYPES if t not in UNSUPPORTED_ITEM_TYPES] + + +@handle_exceptions() +@set_command_context() +def find_command(args: Namespace) -> None: + """Search the Fabric catalog for items.""" + if args.query: + args.query = utils.process_nargs(args.query) + + is_interactive = getattr(args, "fab_mode", None) == fab_constant.FAB_MODE_INTERACTIVE + payload = _build_search_payload(args, is_interactive) + + utils_ui.print_grey(f"Searching catalog for '{args.search_text}'...") + + if is_interactive: + _find_interactive(args, payload) + else: + _find_commandline(args, payload) + + +def _find_interactive(args: Namespace, payload: dict[str, Any]) -> None: + """Fetch and display results page by page, prompting between pages.""" + total_count = 0 + + while True: + response = catalog_api.catalog_search(args, payload) + _raise_on_error(response) + + results = json.loads(response.text) + items = results.get("value", []) + continuation_token = results.get("continuationToken", "") or None + + if not items and total_count == 0: + utils_ui.print_grey("No items found.") + return + + total_count += len(items) + has_more = continuation_token is not None + + count_msg = f"{len(items)} item(s) found" + (" (more available)" if has_more else "") + utils_ui.print_grey("") + utils_ui.print_grey(count_msg) + utils_ui.print_grey("") + + _display_items(args, items) + + if not has_more: + break + + try: + utils_ui.print_grey("") + input("Press any key to continue... (Ctrl+C to stop)") + except (KeyboardInterrupt, EOFError): + utils_ui.print_grey("") + break + + payload = {"continuationToken": continuation_token} + + if total_count > 0: + utils_ui.print_grey("") + utils_ui.print_grey(f"{total_count} total item(s)") + + +def _find_commandline(args: Namespace, payload: dict[str, Any]) -> None: + """Fetch all results across pages and display.""" + all_items: list[dict] = [] + + while True: + response = catalog_api.catalog_search(args, payload) + _raise_on_error(response) + + results = json.loads(response.text) + all_items.extend(results.get("value", [])) + + continuation_token = results.get("continuationToken", "") or None + if not continuation_token: + break + + payload = {"continuationToken": continuation_token} + + if not all_items: + utils_ui.print_grey("No items found.") + return + + utils_ui.print_grey("") + utils_ui.print_grey(f"{len(all_items)} item(s) found") + utils_ui.print_grey("") + + _display_items(args, all_items) + + +def _build_search_payload(args: Namespace, is_interactive: bool) -> dict[str, Any]: + """Build the search request payload from command arguments.""" + request: dict[str, Any] = {"search": args.search_text} + + # Interactive pages through 50 at a time; command-line fetches up to 1000 + request["pageSize"] = 50 if is_interactive else 1000 + + # Build type filter from -P params + type_filter = _parse_type_param(args) + if type_filter: + op = type_filter["operator"] + types = type_filter["values"] + + if op == "eq": + if len(types) == 1: + request["filter"] = f"Type eq '{types[0]}'" + else: + or_clause = " or ".join(f"Type eq '{t}'" for t in types) + request["filter"] = f"({or_clause})" + elif op == "ne": + if len(types) == 1: + request["filter"] = f"Type ne '{types[0]}'" + else: + ne_clause = " and ".join(f"Type ne '{t}'" for t in types) + request["filter"] = f"({ne_clause})" + + return request + + +def _parse_type_param(args: Namespace) -> dict[str, Any] | None: + """Extract and validate item types from -P params. + + Supports: + -P type=Report → eq single + -P type=[Report,Lakehouse] → eq multiple (or) + -P type!=Dashboard → ne single + -P type!=[Dashboard,Report] → ne multiple (and) + Legacy comma syntax also supported: -P type=Report,Lakehouse + + Returns dict with 'operator' ('eq' or 'ne') and 'values' list, or None. + """ + params = getattr(args, "params", None) + if not params: + return None + + # params is a list from argparse nargs="*", e.g. ["type=[Report,Lakehouse]"] + type_value = None + operator = "eq" + for param in params: + if "!=" in param: + key, value = param.split("!=", 1) + if key.lower() == "type": + type_value = value + operator = "ne" + else: + raise FabricCLIError( + f"'{key}' isn't a supported parameter. Supported: type", + fab_constant.ERROR_INVALID_INPUT, + ) + elif "=" in param: + key, value = param.split("=", 1) + if key.lower() == "type": + type_value = value + operator = "eq" + else: + raise FabricCLIError( + f"'{key}' isn't a supported parameter. Supported: type", + fab_constant.ERROR_INVALID_INPUT, + ) + else: + raise FabricCLIError( + f"Invalid parameter format: '{param}'. Use key=value or key!=value.", + fab_constant.ERROR_INVALID_INPUT, + ) + + if not type_value: + return None + + # Parse bracket syntax: [val1,val2] or plain: val1 or legacy: val1,val2 + if type_value.startswith("[") and type_value.endswith("]"): + inner = type_value[1:-1] + types = [t.strip() for t in inner.split(",") if t.strip()] + else: + types = [t.strip() for t in type_value.split(",") if t.strip()] + + # Validate and normalize types (case-insensitive matching) + all_types_lower = {t.lower(): t for t in ALL_ITEM_TYPES} + unsupported_lower = {t.lower() for t in UNSUPPORTED_ITEM_TYPES} + normalized = [] + for t in types: + t_lower = t.lower() + if t_lower in unsupported_lower and operator == "eq": + canonical = all_types_lower.get(t_lower, t) + raise FabricCLIError( + f"'{canonical}' isn't searchable via the catalog search API.", + fab_constant.ERROR_UNSUPPORTED_ITEM_TYPE, + ) + if t_lower not in all_types_lower: + # Suggest close matches instead of dumping the full list + close = [v for k, v in all_types_lower.items() if t_lower in k or k in t_lower] + hint = f" Did you mean {', '.join(close)}?" if close else " Use tab completion to see valid types." + raise FabricCLIError( + f"'{t}' isn't a recognized item type.{hint}", + fab_constant.ERROR_INVALID_ITEM_TYPE, + ) + normalized.append(all_types_lower[t_lower]) + + return {"operator": operator, "values": normalized} + + +def _raise_on_error(response) -> None: + """Raise FabricCLIError if the API response indicates failure.""" + if response.status_code != 200: + try: + error_data = json.loads(response.text) + error_code = error_data.get("errorCode", "UnknownError") + error_message = error_data.get("message", response.text) + except json.JSONDecodeError: + error_code = "UnknownError" + error_message = response.text + + raise FabricCLIError( + f"Catalog search failed: {error_message}", + error_code, + ) + + +def _display_items(args: Namespace, items: list[dict]) -> None: + """Format and display search result items.""" + detailed = getattr(args, "long", False) + + if detailed: + display_items = [] + for item in items: + entry = { + "name": item.get("displayName") or item.get("name"), + "id": item.get("id"), + "type": item.get("type"), + "workspace": item.get("workspaceName"), + "workspace_id": item.get("workspaceId"), + } + if item.get("description"): + entry["description"] = item.get("description") + display_items.append(entry) + else: + has_descriptions = any(item.get("description") for item in items) + + display_items = [] + for item in items: + entry = { + "name": item.get("displayName") or item.get("name"), + "type": item.get("type"), + "workspace": item.get("workspaceName"), + } + if has_descriptions: + entry["description"] = item.get("description") or "" + display_items.append(entry) + + # Truncate descriptions to avoid table wrapping beyond terminal width + if has_descriptions: + _truncate_descriptions(display_items) + + # Apply JMESPath client-side filtering if -q/--query specified + if getattr(args, "query", None): + display_items = utils_jmespath.search(display_items, args.query) + + if detailed: + utils_ui.print_output_format(args, data=display_items, show_key_value_list=True) + else: + utils_ui.print_output_format(args, data=display_items, show_headers=True) + + +def _truncate_descriptions(items: list[dict]) -> None: + """Truncate description column so the table fits within terminal width.""" + term_width = shutil.get_terminal_size((120, 24)).columns + # Calculate width used by other columns (max value length + 2 padding + 1 gap each) + other_fields = ["name", "type", "workspace"] + used = sum( + max((len(str(item.get(f, ""))) for item in items), default=0) + 3 + for f in other_fields + ) + # Also account for "description" header length minimum + max_desc = max(term_width - used - 3, 20) + for item in items: + desc = item.get("description", "") + if len(desc) > max_desc: + item["description"] = desc[: max_desc - 1] + "…" diff --git a/src/fabric_cli/core/fab_parser_setup.py b/src/fabric_cli/core/fab_parser_setup.py index ae91d37a..53a96220 100644 --- a/src/fabric_cli/core/fab_parser_setup.py +++ b/src/fabric_cli/core/fab_parser_setup.py @@ -14,6 +14,7 @@ from fabric_cli.parsers import fab_config_parser as config_parser from fabric_cli.parsers import fab_describe_parser as describe_parser from fabric_cli.parsers import fab_extension_parser as extension_parser +from fabric_cli.parsers import fab_find_parser as find_parser from fabric_cli.parsers import fab_fs_parser as fs_parser from fabric_cli.parsers import fab_global_params from fabric_cli.parsers import fab_jobs_parser as jobs_parser @@ -218,6 +219,7 @@ def create_parser_and_subparsers(): api_parser.register_parser(subparsers) # api auth_parser.register_parser(subparsers) # auth describe_parser.register_parser(subparsers) # desc + find_parser.register_parser(subparsers) # find extension_parser.register_parser(subparsers) # extension # version diff --git a/src/fabric_cli/parsers/fab_find_parser.py b/src/fabric_cli/parsers/fab_find_parser.py new file mode 100644 index 00000000..c9482619 --- /dev/null +++ b/src/fabric_cli/parsers/fab_find_parser.py @@ -0,0 +1,85 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Parser for the find command.""" + +from argparse import Namespace, _SubParsersAction + +from fabric_cli.commands.find import fab_find as find +from fabric_cli.utils import fab_error_parser as utils_error_parser +from fabric_cli.utils import fab_ui as utils_ui + + +COMMAND_FIND_DESCRIPTION = "Search the Fabric catalog for items." + +commands = { + "Description": { + "find": "Search across all workspaces by name, description, or workspace name.", + }, +} + + +def register_parser(subparsers: _SubParsersAction) -> None: + """Register the find command parser.""" + examples = [ + "# search for items by name or description", + "$ find 'sales report'\n", + "# search for lakehouses only", + "$ find 'data' -P type=Lakehouse\n", + "# search for multiple item types (bracket syntax)", + "$ find 'dashboard' -P type=[Report,SemanticModel]\n", + "# exclude a type", + "$ find 'data' -P type!=Dashboard\n", + "# exclude multiple types", + "$ find 'data' -P type!=[Dashboard,Datamart]\n", + "# show detailed output with IDs", + "$ find 'sales' -l\n", + "# combine filters", + "$ find 'finance' -P type=[Warehouse,Lakehouse] -l\n", + "# filter results client-side with JMESPath", + "$ find 'sales' -q \"[?type=='Report']\"\n", + "# project specific fields", + "$ find 'data' -q \"[].{name: name, workspace: workspace}\"", + ] + + parser = subparsers.add_parser( + "find", + help=COMMAND_FIND_DESCRIPTION, + fab_examples=examples, + fab_learnmore=["_"], + ) + + parser.add_argument( + "search_text", + metavar="query", + help="Search text (matches display name, description, and workspace name)", + ) + parser.add_argument( + "-P", + "--params", + required=False, + metavar="", + nargs="*", + help="Parameters in key=value or key!=value format. Use brackets for multiple values: type=[Lakehouse,Notebook]. Use != to exclude: type!=Dashboard", + ) + parser.add_argument( + "-l", + "--long", + action="store_true", + help="Show detailed output. Optional", + ) + parser.add_argument( + "-q", + "--query", + required=False, + nargs="+", + help="JMESPath query to filter. Optional", + ) + + parser.usage = f"{utils_error_parser.get_usage_prog(parser)}" + parser.set_defaults(func=find.find_command) + + +def show_help(args: Namespace) -> None: + """Display help for the find command.""" + utils_ui.display_help(commands, custom_header=COMMAND_FIND_DESCRIPTION) diff --git a/tests/test_commands/find/__init__.py b/tests/test_commands/find/__init__.py new file mode 100644 index 00000000..59e481eb --- /dev/null +++ b/tests/test_commands/find/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. diff --git a/tests/test_commands/find/test_find.py b/tests/test_commands/find/test_find.py new file mode 100644 index 00000000..c752ae41 --- /dev/null +++ b/tests/test_commands/find/test_find.py @@ -0,0 +1,406 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Unit tests for the find command.""" + +import json +from argparse import Namespace +from unittest.mock import MagicMock, patch + +import pytest + +from fabric_cli.commands.find import fab_find +from fabric_cli.client.fab_api_types import ApiResponse +from fabric_cli.core import fab_constant +from fabric_cli.core.fab_exceptions import FabricCLIError + + +# Sample API responses for testing +SAMPLE_RESPONSE_WITH_RESULTS = { + "value": [ + { + "id": "0acd697c-1550-43cd-b998-91bfb12347c6", + "type": "Report", + "catalogEntryType": "FabricItem", + "displayName": "Monthly Sales Revenue", + "description": "Consolidated revenue report for the current fiscal year.", + "workspaceId": "18cd155c-7850-15cd-a998-91bfb12347aa", + "workspaceName": "Sales Department", + }, + { + "id": "123d697c-7848-77cd-b887-91bfb12347cc", + "type": "Lakehouse", + "catalogEntryType": "FabricItem", + "displayName": "Yearly Sales Revenue", + "description": "Consolidated revenue report for the current fiscal year.", + "workspaceId": "18cd155c-7850-15cd-a998-91bfb12347aa", + "workspaceName": "Sales Department", + }, + ], + "continuationToken": "lyJ1257lksfdfG==", +} + +SAMPLE_RESPONSE_EMPTY = { + "value": [], +} + +SAMPLE_RESPONSE_SINGLE = { + "value": [ + { + "id": "abc12345-1234-5678-9abc-def012345678", + "type": "Notebook", + "catalogEntryType": "FabricItem", + "displayName": "Data Analysis", + "description": "Notebook for data analysis tasks.", + "workspaceId": "workspace-id-123", + "workspaceName": "Analytics Team", + }, + ], +} + + +class TestBuildSearchPayload: + """Tests for _build_search_payload function.""" + + def test_basic_query_interactive(self): + """Test basic search query in interactive mode.""" + args = Namespace(search_text="sales report", params=None, query=None) + payload = fab_find._build_search_payload(args, is_interactive=True) + + assert payload["search"] == "sales report" + assert payload["pageSize"] == 50 + assert "filter" not in payload + + def test_basic_query_commandline(self): + """Test basic search query in command-line mode.""" + args = Namespace(search_text="sales report", params=None, query=None) + payload = fab_find._build_search_payload(args, is_interactive=False) + + assert payload["search"] == "sales report" + assert payload["pageSize"] == 1000 + assert "filter" not in payload + + def test_query_with_single_type(self): + """Test search with single type filter via -P.""" + args = Namespace(search_text="report", params=["type=Report"], query=None) + payload = fab_find._build_search_payload(args, is_interactive=False) + + assert payload["search"] == "report" + assert payload["filter"] == "Type eq 'Report'" + + def test_query_with_multiple_types(self): + """Test search with multiple type filters via -P bracket syntax.""" + args = Namespace(search_text="data", params=["type=[Lakehouse,Warehouse]"], query=None) + payload = fab_find._build_search_payload(args, is_interactive=False) + + assert payload["search"] == "data" + assert payload["filter"] == "(Type eq 'Lakehouse' or Type eq 'Warehouse')" + + def test_query_with_multiple_types_legacy_comma(self): + """Test search with multiple type filters via legacy comma syntax.""" + args = Namespace(search_text="data", params=["type=Lakehouse,Warehouse"], query=None) + payload = fab_find._build_search_payload(args, is_interactive=False) + + assert payload["search"] == "data" + assert payload["filter"] == "(Type eq 'Lakehouse' or Type eq 'Warehouse')" + + def test_query_with_ne_single_type(self): + """Test search with ne filter for single type.""" + args = Namespace(search_text="data", params=["type!=Dashboard"], query=None) + payload = fab_find._build_search_payload(args, is_interactive=False) + + assert payload["filter"] == "Type ne 'Dashboard'" + + def test_query_with_ne_multiple_types(self): + """Test search with ne filter for multiple types.""" + args = Namespace(search_text="data", params=["type!=[Dashboard,Datamart]"], query=None) + payload = fab_find._build_search_payload(args, is_interactive=False) + + assert payload["filter"] == "(Type ne 'Dashboard' and Type ne 'Datamart')" + + +class TestParseTypeParam: + """Tests for _parse_type_param function.""" + + def test_no_params(self): + """Test with no params.""" + args = Namespace(params=None) + assert fab_find._parse_type_param(args) is None + + def test_empty_params(self): + """Test with empty params list.""" + args = Namespace(params=[]) + assert fab_find._parse_type_param(args) is None + + def test_single_type(self): + """Test single type value.""" + args = Namespace(params=["type=Report"]) + result = fab_find._parse_type_param(args) + assert result == {"operator": "eq", "values": ["Report"]} + + def test_multiple_types_comma_separated(self): + """Test comma-separated types (legacy syntax).""" + args = Namespace(params=["type=Report,Lakehouse"]) + result = fab_find._parse_type_param(args) + assert result == {"operator": "eq", "values": ["Report", "Lakehouse"]} + + def test_multiple_types_bracket_syntax(self): + """Test bracket syntax for multiple types.""" + args = Namespace(params=["type=[Report,Lakehouse]"]) + result = fab_find._parse_type_param(args) + assert result == {"operator": "eq", "values": ["Report", "Lakehouse"]} + + def test_ne_single_type(self): + """Test ne operator with single type.""" + args = Namespace(params=["type!=Dashboard"]) + result = fab_find._parse_type_param(args) + assert result == {"operator": "ne", "values": ["Dashboard"]} + + def test_ne_multiple_types_bracket(self): + """Test ne operator with bracket syntax.""" + args = Namespace(params=["type!=[Dashboard,Datamart]"]) + result = fab_find._parse_type_param(args) + assert result == {"operator": "ne", "values": ["Dashboard", "Datamart"]} + + def test_ne_unsupported_type_allowed(self): + """Test ne with unsupported type (Dashboard) is allowed — excluding makes sense.""" + args = Namespace(params=["type!=Dashboard"]) + result = fab_find._parse_type_param(args) + assert result == {"operator": "ne", "values": ["Dashboard"]} + + def test_invalid_format_raises_error(self): + """Test invalid param format raises error.""" + args = Namespace(params=["notakeyvalue"]) + with pytest.raises(FabricCLIError) as exc_info: + fab_find._parse_type_param(args) + assert "Invalid parameter format" in str(exc_info.value) + + def test_unknown_param_raises_error(self): + """Test unknown param key raises error.""" + args = Namespace(params=["foo=bar"]) + with pytest.raises(FabricCLIError) as exc_info: + fab_find._parse_type_param(args) + assert "isn't a supported parameter" in str(exc_info.value) + + def test_unknown_param_ne_raises_error(self): + """Test unknown param key with ne raises error.""" + args = Namespace(params=["foo!=bar"]) + with pytest.raises(FabricCLIError) as exc_info: + fab_find._parse_type_param(args) + assert "isn't a supported parameter" in str(exc_info.value) + + def test_unsupported_type_eq_raises_error(self): + """Test error for unsupported item types like Dashboard with eq.""" + args = Namespace(params=["type=Dashboard"]) + with pytest.raises(FabricCLIError) as exc_info: + fab_find._parse_type_param(args) + assert "Dashboard" in str(exc_info.value) + assert "isn't searchable" in str(exc_info.value) + + def test_unknown_type_raises_error(self): + """Test error for unknown item types.""" + args = Namespace(params=["type=InvalidType"]) + with pytest.raises(FabricCLIError) as exc_info: + fab_find._parse_type_param(args) + assert "InvalidType" in str(exc_info.value) + assert "isn't a recognized item type" in str(exc_info.value) + + def test_unknown_type_ne_raises_error(self): + """Test error for unknown item types with ne operator.""" + args = Namespace(params=["type!=InvalidType"]) + with pytest.raises(FabricCLIError) as exc_info: + fab_find._parse_type_param(args) + assert "InvalidType" in str(exc_info.value) + assert "isn't a recognized item type" in str(exc_info.value) + + +class TestDisplayItems: + """Tests for _display_items function.""" + + @patch("fabric_cli.utils.fab_ui.print_output_format") + def test_display_items_table(self, mock_print_format): + """Test displaying items in table mode.""" + args = Namespace(long=False, output_format="text", query=None) + items = SAMPLE_RESPONSE_WITH_RESULTS["value"] + + fab_find._display_items(args, items) + + mock_print_format.assert_called_once() + display_items = mock_print_format.call_args.kwargs["data"] + assert len(display_items) == 2 + assert display_items[0]["name"] == "Monthly Sales Revenue" + assert display_items[0]["type"] == "Report" + assert display_items[0]["workspace"] == "Sales Department" + assert display_items[0]["description"] == "Consolidated revenue report for the current fiscal year." + + @patch("fabric_cli.utils.fab_ui.print_output_format") + def test_display_items_detailed(self, mock_print_format): + """Test displaying items with long flag.""" + args = Namespace(long=True, output_format="text", query=None) + items = SAMPLE_RESPONSE_SINGLE["value"] + + fab_find._display_items(args, items) + + mock_print_format.assert_called_once() + display_items = mock_print_format.call_args.kwargs["data"] + assert len(display_items) == 1 + + item = display_items[0] + assert item["name"] == "Data Analysis" + assert item["type"] == "Notebook" + assert item["workspace"] == "Analytics Team" + assert item["description"] == "Notebook for data analysis tasks." + assert item["id"] == "abc12345-1234-5678-9abc-def012345678" + assert item["workspace_id"] == "workspace-id-123" + + @patch("fabric_cli.utils.fab_ui.print_output_format") + @patch("fabric_cli.utils.fab_jmespath.search") + def test_display_items_with_jmespath(self, mock_jmespath, mock_print_format): + """Test JMESPath filtering is applied when -q is provided.""" + filtered = [{"name": "Monthly Sales Revenue", "type": "Report"}] + mock_jmespath.return_value = filtered + + args = Namespace(long=False, output_format="text", query="[?type=='Report']") + items = SAMPLE_RESPONSE_WITH_RESULTS["value"] + + fab_find._display_items(args, items) + + mock_jmespath.assert_called_once() + mock_print_format.assert_called_once() + display_items = mock_print_format.call_args.kwargs["data"] + assert display_items == filtered + + +class TestRaiseOnError: + """Tests for _raise_on_error function.""" + + def test_success_response(self): + """Test successful response does not raise.""" + response = MagicMock() + response.status_code = 200 + fab_find._raise_on_error(response) # Should not raise + + def test_error_response_raises_fabric_cli_error(self): + """Test error response raises FabricCLIError.""" + response = MagicMock() + response.status_code = 403 + response.text = json.dumps({ + "errorCode": "InsufficientScopes", + "message": "Missing required scope: Catalog.Read.All" + }) + + with pytest.raises(FabricCLIError) as exc_info: + fab_find._raise_on_error(response) + + assert "Catalog search failed" in str(exc_info.value) + assert "Missing required scope" in str(exc_info.value) + + def test_error_response_non_json(self): + """Test error response with non-JSON body.""" + response = MagicMock() + response.status_code = 500 + response.text = "Internal Server Error" + + with pytest.raises(FabricCLIError) as exc_info: + fab_find._raise_on_error(response) + + assert "Catalog search failed" in str(exc_info.value) + + +class TestFindCommandline: + """Tests for _find_commandline function.""" + + @patch("fabric_cli.utils.fab_ui.print_output_format") + @patch("fabric_cli.utils.fab_ui.print_grey") + @patch("fabric_cli.client.fab_api_catalog.catalog_search") + def test_displays_results(self, mock_search, mock_print_grey, mock_print_format): + """Test command-line mode displays results.""" + response = MagicMock() + response.status_code = 200 + response.text = json.dumps(SAMPLE_RESPONSE_SINGLE) + mock_search.return_value = response + + args = Namespace(long=False, output_format="text", query=None) + payload = {"search": "test", "pageSize": 1000} + + fab_find._find_commandline(args, payload) + + mock_search.assert_called_once() + mock_print_format.assert_called_once() + + @patch("fabric_cli.utils.fab_ui.print_grey") + @patch("fabric_cli.client.fab_api_catalog.catalog_search") + def test_empty_results(self, mock_search, mock_print_grey): + """Test command-line mode with no results.""" + response = MagicMock() + response.status_code = 200 + response.text = json.dumps(SAMPLE_RESPONSE_EMPTY) + mock_search.return_value = response + + args = Namespace(long=False, output_format="text", query=None) + payload = {"search": "nothing", "pageSize": 1000} + + fab_find._find_commandline(args, payload) + + mock_print_grey.assert_called_with("No items found.") + + +class TestFindInteractive: + """Tests for _find_interactive function.""" + + @patch("builtins.input", return_value="") + @patch("fabric_cli.utils.fab_ui.print_output_format") + @patch("fabric_cli.utils.fab_ui.print_grey") + @patch("fabric_cli.client.fab_api_catalog.catalog_search") + def test_pages_through_results(self, mock_search, mock_print_grey, mock_print_format, mock_input): + """Test interactive mode pages through multiple responses.""" + # First page has continuation token, second page does not + page1 = MagicMock() + page1.status_code = 200 + page1.text = json.dumps(SAMPLE_RESPONSE_WITH_RESULTS) + + page2 = MagicMock() + page2.status_code = 200 + page2.text = json.dumps(SAMPLE_RESPONSE_SINGLE) + + mock_search.side_effect = [page1, page2] + + args = Namespace(long=False, output_format="text", query=None) + payload = {"search": "sales", "pageSize": 50} + + fab_find._find_interactive(args, payload) + + assert mock_search.call_count == 2 + assert mock_print_format.call_count == 2 + mock_input.assert_called_once_with("Press any key to continue... (Ctrl+C to stop)") + + @patch("builtins.input", side_effect=KeyboardInterrupt) + @patch("fabric_cli.utils.fab_ui.print_output_format") + @patch("fabric_cli.utils.fab_ui.print_grey") + @patch("fabric_cli.client.fab_api_catalog.catalog_search") + def test_ctrl_c_stops_pagination(self, mock_search, mock_print_grey, mock_print_format, mock_input): + """Test Ctrl+C stops pagination.""" + response = MagicMock() + response.status_code = 200 + response.text = json.dumps(SAMPLE_RESPONSE_WITH_RESULTS) + mock_search.return_value = response + + args = Namespace(long=False, output_format="text", query=None) + payload = {"search": "sales", "pageSize": 50} + + fab_find._find_interactive(args, payload) + + # Should only fetch one page (stopped by Ctrl+C) + assert mock_search.call_count == 1 + assert mock_print_format.call_count == 1 + + +class TestSearchableItemTypes: + """Tests for item type lists.""" + + def test_searchable_types_excludes_unsupported(self): + """Test SEARCHABLE_ITEM_TYPES excludes unsupported types.""" + assert "Dashboard" not in fab_find.SEARCHABLE_ITEM_TYPES + assert "Dataflow" in fab_find.SEARCHABLE_ITEM_TYPES + assert "Report" in fab_find.SEARCHABLE_ITEM_TYPES + assert "Lakehouse" in fab_find.SEARCHABLE_ITEM_TYPES