diff --git a/.changes/unreleased/fixed-20260430-130558.yaml b/.changes/unreleased/fixed-20260430-130558.yaml new file mode 100644 index 000000000..16dd7fabb --- /dev/null +++ b/.changes/unreleased/fixed-20260430-130558.yaml @@ -0,0 +1,6 @@ +kind: fixed +body: Refactor `fab tables schema` to use the `deltalake` Python library for schema extraction via ABFSS URI instead of manually parsing Delta log commit files +time: 2026-04-30T13:05:58.364670843Z +custom: + Author: pkontek + AuthorLink: https://github.com/pkontek diff --git a/pyproject.toml b/pyproject.toml index f2c52dc5d..9995a06c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ "requests", "cryptography", "fabric-cicd>=0.3.1", + "deltalake>=0.18.0", ] [project.scripts] diff --git a/requirements-dev.txt b/requirements-dev.txt index 1b831e57e..ca684beb1 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -12,6 +12,7 @@ psutil==7.0.0 requests cryptography fabric-cicd>=0.3.1 +deltalake>=0.18.0 # Testing and Building Requirements tox>=4.20.0 diff --git a/src/fabric_cli/commands/tables/fab_tables_schema.py b/src/fabric_cli/commands/tables/fab_tables_schema.py index 74f3b39dd..bda99d66e 100644 --- a/src/fabric_cli/commands/tables/fab_tables_schema.py +++ b/src/fabric_cli/commands/tables/fab_tables_schema.py @@ -3,78 +3,55 @@ import json from argparse import Namespace -from typing import Optional -from fabric_cli.client import fab_api_onelake as onelake_api +from deltalake import DeltaTable +from deltalake.exceptions import DeltaError + from fabric_cli.core import fab_constant -from fabric_cli.core import fab_handle_context as handle_context +from fabric_cli.core.fab_auth import FabAuth from fabric_cli.core.fab_exceptions import FabricCLIError -from fabric_cli.core.hiearchy.fab_hiearchy import OneLakeItem from fabric_cli.utils import fab_ui -from fabric_cli.utils import fab_util as utils def exec_command(args: Namespace) -> None: - schema = _extract_schema_from_commit_logs(args) - if schema: - fab_ui.print_grey("Schema extracted successfully") - _schema = json.loads(schema)["fields"] - fab_ui.print_output_format(args, data=_schema, show_headers=True) + schema_fields = _get_table_schema(args) + fab_ui.print_grey("Schema extracted successfully") + fab_ui.print_output_format(args, data=schema_fields, show_headers=True) - else: + +def _get_table_schema(args: Namespace) -> list[dict]: + token = FabAuth().get_access_token(fab_constant.SCOPE_ONELAKE_DEFAULT) + if token is None: raise FabricCLIError( - "Failed to extract the table schema. Please ensure the path points to a valid Delta table", - fab_constant.ERROR_INVALID_DETLA_TABLE, + "Failed to obtain access token.", + fab_constant.ERROR_AUTHENTICATION_FAILED, ) - - -def _get_commit_logs(args: Namespace) -> Optional[list[str]]: - _delta_log_path = args.path - _delta_log_path[-1] = _delta_log_path[-1] + "/_delta_log" - - _context = handle_context.get_command_context(_delta_log_path, raise_error=True) - assert isinstance(_context, OneLakeItem) - onelake: OneLakeItem = _context - workspace_id = onelake.workspace.id - item_id = onelake.item.id - local_path = onelake.local_path - - local_path = utils.remove_dot_suffix(local_path) - args.directory = f"{workspace_id}/?recursive=false&resource=filesystem&directory={item_id}/{local_path}&getShortcutMetadata=true" - response = onelake_api.list_tables_files_recursive(args) - - if response.status_code in {200, 201}: - file_names = [f["name"] for f in response.json().get("paths", [])] - json_files = [ - f"{workspace_id}/{item_id}/{f.split('/', 1)[1]}" - for f in file_names - if f.endswith(".json") and f != "_temporary" - ] - json_files.sort(reverse=True) - return json_files - return None - - -def _extract_schema_from_commit_logs(args: Namespace) -> Optional[str]: - commit_logs = _get_commit_logs(args) - - if not commit_logs: - return None - - for log in commit_logs: - args.from_path = log - args.wait = True - response = onelake_api.read(args) - - if response.status_code in {200, 201}: - json_string = response.text - json_objects = json_string.strip().split("\n") - - for obj in json_objects: - commit_data = json.loads(obj) - if "metaData" in commit_data: - metadata = commit_data["metaData"] - schema = metadata["schemaString"] - return schema - - return None + if args.schema: + local_path = f"Tables/{args.schema}/{args.table_name}" + else: + local_path = f"Tables/{args.table_name}" + + table_uri = ( + f"abfss://{args.ws_id}@{fab_constant.API_ENDPOINT_ONELAKE}" + f"/{args.lakehouse_id}/{local_path}" + ) + + try: + table = DeltaTable( + table_uri, + storage_options={ + "bearer_token": token, + "use_fabric_endpoint": "true", + }, + ) + schema_json = table.schema().to_json() + schema_dict = json.loads(schema_json) + schema_fields = schema_dict.get("fields") + if not isinstance(schema_fields, list): + raise ValueError("Delta table schema JSON does not contain a valid 'fields' list.") + return schema_fields + except (DeltaError, json.JSONDecodeError, ValueError) as exc: + raise FabricCLIError( + f"Failed to extract the table schema. Please ensure the path points to a valid Delta table: {exc}", + fab_constant.ERROR_INVALID_DELTA_TABLE, + ) from exc diff --git a/src/fabric_cli/core/fab_constant.py b/src/fabric_cli/core/fab_constant.py index 4fd00e7b9..424299aaa 100644 --- a/src/fabric_cli/core/fab_constant.py +++ b/src/fabric_cli/core/fab_constant.py @@ -19,8 +19,7 @@ ) API_ENDPOINT_POWER_BI = ( - validate_and_get_env_variable( - "FAB_API_ENDPOINT_POWER_BI", "api.powerbi.com") + validate_and_get_env_variable("FAB_API_ENDPOINT_POWER_BI", "api.powerbi.com") + "/v1.0/myorg" ) @@ -264,7 +263,7 @@ ERROR_INVALID_OPERATION = "InvalidOperation" ERROR_INVALID_PATH = "InvalidPath" ERROR_INVALID_PROPERTY = "InvalidProperty" -ERROR_INVALID_DETLA_TABLE = "InvalidDeltaTable" +ERROR_INVALID_DELTA_TABLE = "InvalidDeltaTable" ERROR_INVALID_QUERY_FIELDS = "InvalidQueryFields" ERROR_INVALID_WORKSPACE_TYPE = "InvalidWorkspaceType" ERROR_INVALID_QUERY = "InvalidQuery" @@ -351,4 +350,3 @@ # Invalid query parameters for set command across all fabric resources SET_COMMAND_INVALID_QUERIES = ["id", "type", "workspaceId", "folderId"] - diff --git a/tests/test_commands/commands_parser.py b/tests/test_commands/commands_parser.py index db66f60b1..34695af74 100644 --- a/tests/test_commands/commands_parser.py +++ b/tests/test_commands/commands_parser.py @@ -2,6 +2,7 @@ # Licensed under the MIT License. import platform + from prompt_toolkit.input import DummyInput from prompt_toolkit.output import DummyOutput @@ -12,6 +13,9 @@ from fabric_cli.parsers.fab_config_parser import ( register_parser as register_config_parser, ) +from fabric_cli.parsers.fab_find_parser import ( + register_parser as register_find_parser, +) from fabric_cli.parsers.fab_fs_parser import ( register_assign_parser, register_cd_parser, @@ -32,13 +36,13 @@ register_stop_parser, register_unassign_parser, ) -from fabric_cli.parsers.fab_find_parser import ( - register_parser as register_find_parser, -) from fabric_cli.parsers.fab_jobs_parser import register_parser as register_jobs_parser from fabric_cli.parsers.fab_labels_parser import ( register_parser as register_labels_parser, ) +from fabric_cli.parsers.fab_tables_parser import ( + register_parser as register_tables_parser, +) parserHandlers = [ register_labels_parser, @@ -65,6 +69,7 @@ register_rm_parser, register_mkdir_parser, register_jobs_parser, + register_tables_parser, ] diff --git a/tests/test_commands/recordings/test_commands/test_tables_schema/class_setup.yaml b/tests/test_commands/recordings/test_commands/test_tables_schema/class_setup.yaml new file mode 100644 index 000000000..a6d2e8bd5 --- /dev/null +++ b/tests/test_commands/recordings/test_commands/test_tables_schema/class_setup.yaml @@ -0,0 +1,180 @@ +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Type: + - application/json + User-Agent: + - ms-fabric-cli-test/1.0.0 + method: GET + uri: https://api.fabric.microsoft.com/v1/workspaces + response: + body: + string: '{"value": [{"id": "94da8ea5-0bd6-4a9e-b717-5fdb482f4c71", "displayName": + "My workspace", "description": "", "type": "Personal"}]}' + headers: + Content-Type: + - application/json; charset=utf-8 + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Type: + - application/json + User-Agent: + - ms-fabric-cli-test/1.0.0 + method: GET + uri: https://api.fabric.microsoft.com/v1/workspaces + response: + body: + string: '{"value": [{"id": "94da8ea5-0bd6-4a9e-b717-5fdb482f4c71", "displayName": + "My workspace", "description": "", "type": "Personal"}]}' + headers: + Content-Type: + - application/json; charset=utf-8 + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Type: + - application/json + User-Agent: + - ms-fabric-cli-test/1.0.0 + method: GET + uri: https://api.fabric.microsoft.com/v1/capacities + response: + body: + string: '{"value": [{"id": "00000000-0000-0000-0000-000000000004", "displayName": + "mocked_fabriccli_capacity_name", "sku": "F2", "region": "West Europe", "state": + "Active"}]}' + headers: + Content-Type: + - application/json; charset=utf-8 + status: + code: 200 + message: OK +- request: + body: '{"displayName": "fabriccli_WorkspacePerTestclass_000001", "capacityId": "00000000-0000-0000-0000-000000000004"}' + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Type: + - application/json + User-Agent: + - ms-fabric-cli-test/1.0.0 + method: POST + uri: https://api.fabric.microsoft.com/v1/workspaces + response: + body: + string: '{"id": "d5e6f7a8-b9c0-d1e2-f3a4-b5c6d7e8f9a0", "displayName": "fabriccli_WorkspacePerTestclass_000001", + "type": "Workspace", "capacityId": "00000000-0000-0000-0000-000000000004"}' + headers: + Content-Type: + - application/json; charset=utf-8 + status: + code: 201 + message: Created +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Type: + - application/json + User-Agent: + - ms-fabric-cli-test/1.0.0 + method: GET + uri: https://api.fabric.microsoft.com/v1/workspaces + response: + body: + string: '{"value": [{"id": "94da8ea5-0bd6-4a9e-b717-5fdb482f4c71", "displayName": + "My workspace", "description": "", "type": "Personal"}, {"id": "d5e6f7a8-b9c0-d1e2-f3a4-b5c6d7e8f9a0", + "displayName": "fabriccli_WorkspacePerTestclass_000001", "type": "Workspace", "capacityId": + "00000000-0000-0000-0000-000000000004"}]}' + headers: + Content-Type: + - application/json; charset=utf-8 + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Type: + - application/json + User-Agent: + - ms-fabric-cli-test/1.0.0 + method: GET + uri: https://api.fabric.microsoft.com/v1/workspaces/d5e6f7a8-b9c0-d1e2-f3a4-b5c6d7e8f9a0/items + response: + body: + string: '{"value": []}' + headers: + Content-Type: + - application/json; charset=utf-8 + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '0' + Content-Type: + - application/json + User-Agent: + - ms-fabric-cli-test/1.0.0 + method: DELETE + uri: https://api.fabric.microsoft.com/v1/workspaces/d5e6f7a8-b9c0-d1e2-f3a4-b5c6d7e8f9a0 + response: + body: + string: '' + headers: + Content-Type: + - application/octet-stream + status: + code: 200 + message: OK +version: 1 diff --git a/tests/test_commands/recordings/test_commands/test_tables_schema/test_table_schema_success.yaml b/tests/test_commands/recordings/test_commands/test_tables_schema/test_table_schema_success.yaml new file mode 100644 index 000000000..67735e36a --- /dev/null +++ b/tests/test_commands/recordings/test_commands/test_tables_schema/test_table_schema_success.yaml @@ -0,0 +1,258 @@ +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Type: + - application/json + User-Agent: + - ms-fabric-cli-test/1.0.0 + method: GET + uri: https://api.fabric.microsoft.com/v1/workspaces + response: + body: + string: '{"value": [{"id": "94da8ea5-0bd6-4a9e-b717-5fdb482f4c71", "displayName": + "My workspace", "description": "", "type": "Personal"}, {"id": "d5e6f7a8-b9c0-d1e2-f3a4-b5c6d7e8f9a0", + "displayName": "fabriccli_WorkspacePerTestclass_000001", "type": "Workspace", "capacityId": + "00000000-0000-0000-0000-000000000004"}]}' + headers: + Content-Type: + - application/json; charset=utf-8 + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Type: + - application/json + User-Agent: + - ms-fabric-cli-test/1.0.0 + method: GET + uri: https://api.fabric.microsoft.com/v1/workspaces/d5e6f7a8-b9c0-d1e2-f3a4-b5c6d7e8f9a0/items + response: + body: + string: '{"value": []}' + headers: + Content-Type: + - application/json; charset=utf-8 + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Type: + - application/json + User-Agent: + - ms-fabric-cli-test/1.0.0 + method: GET + uri: https://api.fabric.microsoft.com/v1/workspaces/d5e6f7a8-b9c0-d1e2-f3a4-b5c6d7e8f9a0/items + response: + body: + string: '{"value": []}' + headers: + Content-Type: + - application/json; charset=utf-8 + status: + code: 200 + message: OK +- request: + body: '{"displayName": "fabcli000001", "type": "Lakehouse", "folderId": null}' + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Type: + - application/json + User-Agent: + - ms-fabric-cli-test/1.0.0 + method: POST + uri: https://api.fabric.microsoft.com/v1/workspaces/d5e6f7a8-b9c0-d1e2-f3a4-b5c6d7e8f9a0/lakehouses + response: + body: + string: '{"id": "e6f7a8b9-c0d1-e2f3-a4b5-c6d7e8f9a0b1", "type": "Lakehouse", + "displayName": "fabcli000001", "workspaceId": "d5e6f7a8-b9c0-d1e2-f3a4-b5c6d7e8f9a0"}' + headers: + Content-Type: + - application/json; charset=utf-8 + status: + code: 201 + message: Created +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Type: + - application/json + User-Agent: + - ms-fabric-cli-test/1.0.0 + method: GET + uri: https://api.fabric.microsoft.com/v1/workspaces + response: + body: + string: '{"value": [{"id": "94da8ea5-0bd6-4a9e-b717-5fdb482f4c71", "displayName": + "My workspace", "description": "", "type": "Personal"}, {"id": "d5e6f7a8-b9c0-d1e2-f3a4-b5c6d7e8f9a0", + "displayName": "fabriccli_WorkspacePerTestclass_000001", "type": "Workspace", "capacityId": + "00000000-0000-0000-0000-000000000004"}]}' + headers: + Content-Type: + - application/json; charset=utf-8 + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Type: + - application/json + User-Agent: + - ms-fabric-cli-test/1.0.0 + method: GET + uri: https://api.fabric.microsoft.com/v1/workspaces/d5e6f7a8-b9c0-d1e2-f3a4-b5c6d7e8f9a0/items + response: + body: + string: '{"value": [{"id": "e6f7a8b9-c0d1-e2f3-a4b5-c6d7e8f9a0b1", "type": "Lakehouse", + "displayName": "fabcli000001", "workspaceId": "d5e6f7a8-b9c0-d1e2-f3a4-b5c6d7e8f9a0"}]}' + headers: + Content-Type: + - application/json; charset=utf-8 + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Type: + - application/json + User-Agent: + - ms-fabric-cli-test/1.0.0 + method: HEAD + uri: https://onelake.dfs.fabric.microsoft.com/d5e6f7a8-b9c0-d1e2-f3a4-b5c6d7e8f9a0/e6f7a8b9-c0d1-e2f3-a4b5-c6d7e8f9a0b1/Tables/my_table + response: + body: + string: '' + headers: + Content-Type: + - application/octet-stream + x-ms-resource-type: + - directory + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Type: + - application/json + User-Agent: + - ms-fabric-cli-test/1.0.0 + method: GET + uri: https://api.fabric.microsoft.com/v1/workspaces + response: + body: + string: '{"value": [{"id": "94da8ea5-0bd6-4a9e-b717-5fdb482f4c71", "displayName": + "My workspace", "description": "", "type": "Personal"}, {"id": "d5e6f7a8-b9c0-d1e2-f3a4-b5c6d7e8f9a0", + "displayName": "fabriccli_WorkspacePerTestclass_000001", "type": "Workspace", "capacityId": + "00000000-0000-0000-0000-000000000004"}]}' + headers: + Content-Type: + - application/json; charset=utf-8 + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Type: + - application/json + User-Agent: + - ms-fabric-cli-test/1.0.0 + method: GET + uri: https://api.fabric.microsoft.com/v1/workspaces/d5e6f7a8-b9c0-d1e2-f3a4-b5c6d7e8f9a0/items + response: + body: + string: '{"value": [{"id": "e6f7a8b9-c0d1-e2f3-a4b5-c6d7e8f9a0b1", "type": "Lakehouse", + "displayName": "fabcli000001", "workspaceId": "d5e6f7a8-b9c0-d1e2-f3a4-b5c6d7e8f9a0"}]}' + headers: + Content-Type: + - application/json; charset=utf-8 + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '0' + Content-Type: + - application/json + User-Agent: + - ms-fabric-cli-test/1.0.0 + method: DELETE + uri: https://api.fabric.microsoft.com/v1/workspaces/d5e6f7a8-b9c0-d1e2-f3a4-b5c6d7e8f9a0/items/e6f7a8b9-c0d1-e2f3-a4b5-c6d7e8f9a0b1 + response: + body: + string: '' + headers: + Content-Type: + - application/octet-stream + status: + code: 200 + message: OK +version: 1 diff --git a/tests/test_commands/test_tables_schema.py b/tests/test_commands/test_tables_schema.py new file mode 100644 index 000000000..3356d5baf --- /dev/null +++ b/tests/test_commands/test_tables_schema.py @@ -0,0 +1,232 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import json +from argparse import Namespace +from unittest.mock import MagicMock, patch + +import pytest +from deltalake.exceptions import DeltaError, TableNotFoundError + +from fabric_cli.commands.tables import fab_tables_schema +from fabric_cli.core import fab_constant +from fabric_cli.core.fab_exceptions import FabricCLIError +from fabric_cli.core.fab_types import ItemType +from tests.conftest import mock_questionary_print # noqa: F401 +from tests.test_commands.commands_parser import CLIExecutor + + +class TestTablesSchemaUnit: + """Unit tests for table schema command - direct function calls without VCR.""" + + @pytest.fixture + def mock_auth(self): + with patch("fabric_cli.commands.tables.fab_tables_schema.FabAuth") as mock: + instance = MagicMock() + instance.get_access_token.return_value = "mock_token" + mock.return_value = instance + yield mock + + @pytest.fixture + def mock_delta_table(self): + with patch("fabric_cli.commands.tables.fab_tables_schema.DeltaTable") as mock: + yield mock + + def _make_delta_table_mock(self, mock_delta_table, schema_json): + mock_arrow_schema = MagicMock() + mock_arrow_schema.to_json.return_value = schema_json + mock_table_instance = MagicMock() + mock_table_instance.schema.return_value = mock_arrow_schema + mock_delta_table.return_value = mock_table_instance + + def test_get_table_schema_success(self, mock_auth, mock_delta_table): + """Test successful schema extraction.""" + args = Namespace( + ws_id="test-ws-id", + lakehouse_id="test-lakehouse-id", + table_name="test_table", + schema=None, + ) + + mock_schema = { + "fields": [ + {"name": "id", "type": "integer", "nullable": False, "metadata": {}}, + {"name": "name", "type": "string", "nullable": True, "metadata": {}}, + ] + } + self._make_delta_table_mock(mock_delta_table, json.dumps(mock_schema)) + + result = fab_tables_schema._get_table_schema(args) + + assert isinstance(result, list) + assert len(result) == 2 + assert result[0]["name"] == "id" + assert result[0]["type"] == "integer" + assert result[1]["name"] == "name" + assert result[1]["type"] == "string" + + mock_delta_table.assert_called_once() + call_args = mock_delta_table.call_args + assert "test-lakehouse-id" in call_args[0][0] + assert "Tables/test_table" in call_args[0][0] + assert call_args[1]["storage_options"]["bearer_token"] == "mock_token" + assert call_args[1]["storage_options"]["use_fabric_endpoint"] == "true" + + def test_get_table_schema_with_explicit_schema_success(self, mock_auth, mock_delta_table): + """Test schema extraction with explicit schema name (e.g., dbo).""" + args = Namespace( + ws_id="test-ws-id", + lakehouse_id="test-lakehouse-id", + table_name="test_table", + schema="dbo", + ) + + mock_schema = { + "fields": [ + {"name": "col1", "type": "long", "nullable": True, "metadata": {}}, + ] + } + self._make_delta_table_mock(mock_delta_table, json.dumps(mock_schema)) + + result = fab_tables_schema._get_table_schema(args) + + call_args = mock_delta_table.call_args + assert "Tables/dbo/test_table" in call_args[0][0] + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0]["name"] == "col1" + + @pytest.mark.parametrize("error_cls", [TableNotFoundError, DeltaError]) + def test_get_table_schema_delta_exceptions(self, mock_auth, mock_delta_table, error_cls): + """Test that DeltaTable errors are mapped to FabricCLIError.""" + args = Namespace( + ws_id="test-ws-id", + lakehouse_id="test-lakehouse-id", + table_name="test_table", + schema=None, + ) + + mock_delta_table.side_effect = error_cls("error") + + with pytest.raises(FabricCLIError) as exc_info: + fab_tables_schema._get_table_schema(args) + + assert exc_info.value.status_code == fab_constant.ERROR_INVALID_DELTA_TABLE + assert "Failed to extract the table schema" in exc_info.value.message + + def test_get_table_schema_invalid_json_error(self, mock_auth, mock_delta_table): + """Test invalid JSON in schema is handled.""" + args = Namespace( + ws_id="test-ws-id", + lakehouse_id="test-lakehouse-id", + table_name="test_table", + schema=None, + ) + + self._make_delta_table_mock(mock_delta_table, "invalid json {") + + with pytest.raises(FabricCLIError) as exc_info: + fab_tables_schema._get_table_schema(args) + + assert exc_info.value.status_code == fab_constant.ERROR_INVALID_DELTA_TABLE + assert "Failed to extract the table schema" in exc_info.value.message + + def test_get_table_schema_missing_fields_key(self, mock_auth, mock_delta_table): + """Test schema JSON without 'fields' key is handled.""" + args = Namespace( + ws_id="test-ws-id", + lakehouse_id="test-lakehouse-id", + table_name="test_table", + schema=None, + ) + + self._make_delta_table_mock(mock_delta_table, json.dumps({"some_other_key": "value"})) + + with pytest.raises(FabricCLIError) as exc_info: + fab_tables_schema._get_table_schema(args) + + assert exc_info.value.status_code == fab_constant.ERROR_INVALID_DELTA_TABLE + assert "Failed to extract the table schema" in exc_info.value.message + + def test_get_table_schema_fields_not_list(self, mock_auth, mock_delta_table): + """Test schema JSON with 'fields' not being a list is handled.""" + args = Namespace( + ws_id="test-ws-id", + lakehouse_id="test-lakehouse-id", + table_name="test_table", + schema=None, + ) + + self._make_delta_table_mock(mock_delta_table, json.dumps({"fields": "not a list"})) + + with pytest.raises(FabricCLIError) as exc_info: + fab_tables_schema._get_table_schema(args) + + assert exc_info.value.status_code == fab_constant.ERROR_INVALID_DELTA_TABLE + assert "Failed to extract the table schema" in exc_info.value.message + + def test_get_table_schema_verifies_abfss_uri_format(self, mock_auth, mock_delta_table): + """Test that table URI is correctly formatted with ABFSS protocol.""" + args = Namespace( + ws_id="workspace-guid-123", + lakehouse_id="lakehouse-guid-456", + table_name="my_table", + schema=None, + ) + + mock_schema = { + "fields": [ + {"name": "col1", "type": "string", "nullable": True, "metadata": {}} + ] + } + self._make_delta_table_mock(mock_delta_table, json.dumps(mock_schema)) + + result = fab_tables_schema._get_table_schema(args) + + call_args = mock_delta_table.call_args + table_uri = call_args[0][0] + + assert table_uri.startswith("abfss://workspace-guid-123@") + assert "lakehouse-guid-456" in table_uri + assert "Tables/my_table" in table_uri + + storage_options = call_args[1]["storage_options"] + assert storage_options["bearer_token"] == "mock_token" + assert storage_options["use_fabric_endpoint"] == "true" + + assert isinstance(result, list) + assert len(result) == 1 + + +class TestTablesSchemaIntegration: + """Integration tests for table schema command - validates full dispatch stack.""" + + def test_table_schema_success( + self, + item_factory, + cli_executor: CLIExecutor, + mock_questionary_print, + ): + lakehouse = item_factory(ItemType.LAKEHOUSE) + + mock_questionary_print.reset_mock() + + with patch( + "fabric_cli.commands.tables.fab_tables_schema.DeltaTable" + ) as mock_dt, patch( + "fabric_cli.commands.tables.fab_tables_schema.FabAuth" + ) as mock_auth: + mock_auth.return_value.get_access_token.return_value = "mock_token" + mock_table = MagicMock() + mock_table.schema.return_value.to_json.return_value = json.dumps({ + "fields": [{"name": "id", "type": "integer", "nullable": False, "metadata": {}}] + }) + mock_dt.return_value = mock_table + + cli_executor.exec_command( + f"table schema {lakehouse.full_path}/Tables/my_table" + ) + + calls = mock_questionary_print.call_args_list + assert any("Schema extracted successfully" in str(c) for c in calls)