From c22f70e3f8854a5fe6baac3e6ef7f98472d204cf Mon Sep 17 00:00:00 2001 From: Piotr Kontek Date: Thu, 30 Apr 2026 13:45:49 +0200 Subject: [PATCH 01/32] fix: Add deltalake dependency and refactor schema extraction logic --- pyproject.toml | 1 + .../commands/tables/fab_tables_schema.py | 91 ++++++------------- 2 files changed, 30 insertions(+), 62 deletions(-) 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/src/fabric_cli/commands/tables/fab_tables_schema.py b/src/fabric_cli/commands/tables/fab_tables_schema.py index 74f3b39dd..fbe2c4dd1 100644 --- a/src/fabric_cli/commands/tables/fab_tables_schema.py +++ b/src/fabric_cli/commands/tables/fab_tables_schema.py @@ -3,78 +3,45 @@ 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 TableNotFoundError + 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) + +def _get_table_schema(args: Namespace) -> list[dict]: + token = FabAuth().get_access_token(fab_constant.SCOPE_ONELAKE_DEFAULT) + 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", + }, + ) + return table.schema().json()["fields"] + except TableNotFoundError: raise FabricCLIError( "Failed to extract the table schema. Please ensure the path points to a valid Delta table", fab_constant.ERROR_INVALID_DETLA_TABLE, ) - - -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 From c0dd71bba830a5b8d7ba588ddcb33ad4f54c0476 Mon Sep 17 00:00:00 2001 From: Piotr Kontek Date: Thu, 30 Apr 2026 15:08:21 +0200 Subject: [PATCH 02/32] fix: Refactor `fab tables schema` to utilize `deltalake` library for schema extraction via ABFSS URI --- .changes/unreleased/fixed-20260430-130558.yaml | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changes/unreleased/fixed-20260430-130558.yaml 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 From f0327bc075eeed1b249bbef3896a7e75dee41d8e Mon Sep 17 00:00:00 2001 From: Piotr Kontek Date: Thu, 30 Apr 2026 15:16:20 +0200 Subject: [PATCH 03/32] fix: Correct typo in error constant for invalid Delta table --- src/fabric_cli/commands/tables/fab_tables_schema.py | 2 +- src/fabric_cli/core/fab_constant.py | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/fabric_cli/commands/tables/fab_tables_schema.py b/src/fabric_cli/commands/tables/fab_tables_schema.py index fbe2c4dd1..5d2ecad27 100644 --- a/src/fabric_cli/commands/tables/fab_tables_schema.py +++ b/src/fabric_cli/commands/tables/fab_tables_schema.py @@ -43,5 +43,5 @@ def _get_table_schema(args: Namespace) -> list[dict]: except TableNotFoundError: raise FabricCLIError( "Failed to extract the table schema. Please ensure the path points to a valid Delta table", - fab_constant.ERROR_INVALID_DETLA_TABLE, + fab_constant.ERROR_INVALID_DELTA_TABLE, ) 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"] - From 746e8f6e7b76cde7058554ad930ff5bf5149a995 Mon Sep 17 00:00:00 2001 From: pkontek Date: Thu, 30 Apr 2026 15:26:53 +0200 Subject: [PATCH 04/32] Remove unused import Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/fabric_cli/commands/tables/fab_tables_schema.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/fabric_cli/commands/tables/fab_tables_schema.py b/src/fabric_cli/commands/tables/fab_tables_schema.py index 5d2ecad27..a9aae2cf2 100644 --- a/src/fabric_cli/commands/tables/fab_tables_schema.py +++ b/src/fabric_cli/commands/tables/fab_tables_schema.py @@ -1,7 +1,6 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -import json from argparse import Namespace from deltalake import DeltaTable From ca07e175fad689014b232f2c84677b58c2bd1180 Mon Sep 17 00:00:00 2001 From: Piotr Kontek Date: Thu, 7 May 2026 09:56:10 +0200 Subject: [PATCH 05/32] fix: Replace TableNotFoundError with DeltaError for schema extraction failure handling --- src/fabric_cli/commands/tables/fab_tables_schema.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/fabric_cli/commands/tables/fab_tables_schema.py b/src/fabric_cli/commands/tables/fab_tables_schema.py index 5d2ecad27..6ba672823 100644 --- a/src/fabric_cli/commands/tables/fab_tables_schema.py +++ b/src/fabric_cli/commands/tables/fab_tables_schema.py @@ -5,7 +5,7 @@ from argparse import Namespace from deltalake import DeltaTable -from deltalake.exceptions import TableNotFoundError +from deltalake.exceptions import DeltaError from fabric_cli.core import fab_constant from fabric_cli.core.fab_auth import FabAuth @@ -40,7 +40,7 @@ def _get_table_schema(args: Namespace) -> list[dict]: }, ) return table.schema().json()["fields"] - except TableNotFoundError: + except DeltaError: raise FabricCLIError( "Failed to extract the table schema. Please ensure the path points to a valid Delta table", fab_constant.ERROR_INVALID_DELTA_TABLE, From baaba5dd0d84ad57f7dabaddd68e038b2a6c70d0 Mon Sep 17 00:00:00 2001 From: Piotr Kontek Date: Thu, 7 May 2026 10:24:15 +0200 Subject: [PATCH 06/32] fix: Update schema extraction to use json.loads for DeltaTable schema --- src/fabric_cli/commands/tables/fab_tables_schema.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/fabric_cli/commands/tables/fab_tables_schema.py b/src/fabric_cli/commands/tables/fab_tables_schema.py index 07d9d3548..90e5d499f 100644 --- a/src/fabric_cli/commands/tables/fab_tables_schema.py +++ b/src/fabric_cli/commands/tables/fab_tables_schema.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +import json from argparse import Namespace from deltalake import DeltaTable @@ -38,7 +39,8 @@ def _get_table_schema(args: Namespace) -> list[dict]: "use_fabric_endpoint": "true", }, ) - return table.schema().json()["fields"] + schema_dict = json.loads(table.schema().to_json()) + return schema_dict["fields"] except DeltaError: raise FabricCLIError( "Failed to extract the table schema. Please ensure the path points to a valid Delta table", From 8221723ad331c11c283dec10938a06c710073bb7 Mon Sep 17 00:00:00 2001 From: pkontek Date: Thu, 7 May 2026 10:31:48 +0200 Subject: [PATCH 07/32] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/fabric_cli/commands/tables/fab_tables_schema.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/fabric_cli/commands/tables/fab_tables_schema.py b/src/fabric_cli/commands/tables/fab_tables_schema.py index 90e5d499f..c015fc3b9 100644 --- a/src/fabric_cli/commands/tables/fab_tables_schema.py +++ b/src/fabric_cli/commands/tables/fab_tables_schema.py @@ -39,9 +39,13 @@ def _get_table_schema(args: Namespace) -> list[dict]: "use_fabric_endpoint": "true", }, ) - schema_dict = json.loads(table.schema().to_json()) - return schema_dict["fields"] - except DeltaError: + 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, TypeError, KeyError, ValueError): raise FabricCLIError( "Failed to extract the table schema. Please ensure the path points to a valid Delta table", fab_constant.ERROR_INVALID_DELTA_TABLE, From 101257290e596e8197dd2f992b531467af727376 Mon Sep 17 00:00:00 2001 From: Piotr Kontek Date: Thu, 7 May 2026 11:04:28 +0200 Subject: [PATCH 08/32] fix: Add unit tests for table schema command --- tests/test_commands/commands_parser.py | 11 +- tests/test_commands/test_tables.py | 342 +++++++++++++++++++++++++ 2 files changed, 350 insertions(+), 3 deletions(-) create mode 100644 tests/test_commands/test_tables.py 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/test_tables.py b/tests/test_commands/test_tables.py new file mode 100644 index 000000000..71e99fa9f --- /dev/null +++ b/tests/test_commands/test_tables.py @@ -0,0 +1,342 @@ +# 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 + + +class TestTablesSchemaUnit: + """Unit tests for table schema command - direct function calls without VCR.""" + + def test_get_table_schema_success(self): + """Test successful schema extraction.""" + # Setup + 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": {}}, + ] + } + + # Mock DeltaTable + with patch( + "fabric_cli.commands.tables.fab_tables_schema.DeltaTable" + ) as mock_delta_table, patch( + "fabric_cli.commands.tables.fab_tables_schema.FabAuth" + ) as mock_auth: + + # Configure mocks + mock_auth_instance = MagicMock() + mock_auth_instance.get_access_token.return_value = "mock_token" + mock_auth.return_value = mock_auth_instance + + mock_table_instance = MagicMock() + mock_arrow_schema = MagicMock() + mock_arrow_schema.to_json.return_value = json.dumps(mock_schema) + mock_table_instance.schema.return_value = mock_arrow_schema + mock_delta_table.return_value = mock_table_instance + + # Call function + result = fab_tables_schema._get_table_schema(args) + + # Assert + 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" + + # Verify DeltaTable was called correctly + 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): + """Test schema extraction with explicit schema name (e.g., dbo).""" + # Setup + 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": {}}, + ] + } + + # Mock DeltaTable + with patch( + "fabric_cli.commands.tables.fab_tables_schema.DeltaTable" + ) as mock_delta_table, patch( + "fabric_cli.commands.tables.fab_tables_schema.FabAuth" + ) as mock_auth: + + # Configure mocks + mock_auth_instance = MagicMock() + mock_auth_instance.get_access_token.return_value = "mock_token" + mock_auth.return_value = mock_auth_instance + + mock_table_instance = MagicMock() + mock_arrow_schema = MagicMock() + mock_arrow_schema.to_json.return_value = json.dumps(mock_schema) + mock_table_instance.schema.return_value = mock_arrow_schema + mock_delta_table.return_value = mock_table_instance + + # Call function + result = fab_tables_schema._get_table_schema(args) + + # Verify table URI includes schema path + call_args = mock_delta_table.call_args + assert "Tables/dbo/test_table" in call_args[0][0] + + # Assert result + assert isinstance(result, list) + assert len(result) == 1 + assert result[0]["name"] == "col1" + + def test_get_table_schema_table_not_found_error(self): + """Test TableNotFoundError is mapped to FabricCLIError.""" + # Setup + args = Namespace( + ws_id="test-ws-id", + lakehouse_id="test-lakehouse-id", + table_name="nonexistent", + schema=None, + ) + + # Mock DeltaTable to raise TableNotFoundError + with patch( + "fabric_cli.commands.tables.fab_tables_schema.DeltaTable" + ) as mock_delta_table, patch( + "fabric_cli.commands.tables.fab_tables_schema.FabAuth" + ) as mock_auth: + + mock_auth_instance = MagicMock() + mock_auth_instance.get_access_token.return_value = "mock_token" + mock_auth.return_value = mock_auth_instance + + mock_delta_table.side_effect = TableNotFoundError("Table not found") + + # Call function and expect error + with pytest.raises(FabricCLIError) as exc_info: + fab_tables_schema._get_table_schema(args) + + # Assert error details + 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_delta_error(self): + """Test generic DeltaError is mapped to FabricCLIError.""" + # Setup + args = Namespace( + ws_id="test-ws-id", + lakehouse_id="test-lakehouse-id", + table_name="test_table", + schema=None, + ) + + # Mock DeltaTable to raise DeltaError + with patch( + "fabric_cli.commands.tables.fab_tables_schema.DeltaTable" + ) as mock_delta_table, patch( + "fabric_cli.commands.tables.fab_tables_schema.FabAuth" + ) as mock_auth: + + mock_auth_instance = MagicMock() + mock_auth_instance.get_access_token.return_value = "mock_token" + mock_auth.return_value = mock_auth_instance + + mock_delta_table.side_effect = DeltaError("Generic delta error") + + # Call function and expect error + with pytest.raises(FabricCLIError) as exc_info: + fab_tables_schema._get_table_schema(args) + + # Assert error details + 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): + """Test invalid JSON in schema is handled.""" + # Setup + args = Namespace( + ws_id="test-ws-id", + lakehouse_id="test-lakehouse-id", + table_name="test_table", + schema=None, + ) + + # Mock DeltaTable to return invalid JSON + with patch( + "fabric_cli.commands.tables.fab_tables_schema.DeltaTable" + ) as mock_delta_table, patch( + "fabric_cli.commands.tables.fab_tables_schema.FabAuth" + ) as mock_auth: + + mock_auth_instance = MagicMock() + mock_auth_instance.get_access_token.return_value = "mock_token" + mock_auth.return_value = mock_auth_instance + + mock_table_instance = MagicMock() + mock_arrow_schema = MagicMock() + mock_arrow_schema.to_json.return_value = "invalid json {" + mock_table_instance.schema.return_value = mock_arrow_schema + mock_delta_table.return_value = mock_table_instance + + # Call function and expect error + with pytest.raises(FabricCLIError) as exc_info: + fab_tables_schema._get_table_schema(args) + + # Assert error details + 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): + """Test schema JSON without 'fields' key is handled.""" + # Setup + args = Namespace( + ws_id="test-ws-id", + lakehouse_id="test-lakehouse-id", + table_name="test_table", + schema=None, + ) + + mock_schema = {"some_other_key": "value"} + + # Mock DeltaTable + with patch( + "fabric_cli.commands.tables.fab_tables_schema.DeltaTable" + ) as mock_delta_table, patch( + "fabric_cli.commands.tables.fab_tables_schema.FabAuth" + ) as mock_auth: + + mock_auth_instance = MagicMock() + mock_auth_instance.get_access_token.return_value = "mock_token" + mock_auth.return_value = mock_auth_instance + + mock_table_instance = MagicMock() + mock_arrow_schema = MagicMock() + mock_arrow_schema.to_json.return_value = json.dumps(mock_schema) + mock_table_instance.schema.return_value = mock_arrow_schema + mock_delta_table.return_value = mock_table_instance + + # Call function and expect error + with pytest.raises(FabricCLIError) as exc_info: + fab_tables_schema._get_table_schema(args) + + # Assert error details + 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): + """Test schema JSON with 'fields' not being a list is handled.""" + # Setup + args = Namespace( + ws_id="test-ws-id", + lakehouse_id="test-lakehouse-id", + table_name="test_table", + schema=None, + ) + + mock_schema = {"fields": "not a list"} + + # Mock DeltaTable + with patch( + "fabric_cli.commands.tables.fab_tables_schema.DeltaTable" + ) as mock_delta_table, patch( + "fabric_cli.commands.tables.fab_tables_schema.FabAuth" + ) as mock_auth: + + mock_auth_instance = MagicMock() + mock_auth_instance.get_access_token.return_value = "mock_token" + mock_auth.return_value = mock_auth_instance + + mock_table_instance = MagicMock() + mock_arrow_schema = MagicMock() + mock_arrow_schema.to_json.return_value = json.dumps(mock_schema) + mock_table_instance.schema.return_value = mock_arrow_schema + mock_delta_table.return_value = mock_table_instance + + # Call function and expect error + with pytest.raises(FabricCLIError) as exc_info: + fab_tables_schema._get_table_schema(args) + + # Assert error details + 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): + """Test that table URI is correctly formatted with ABFSS protocol.""" + # Setup + 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": {}} + ] + } + + # Mock DeltaTable + with patch( + "fabric_cli.commands.tables.fab_tables_schema.DeltaTable" + ) as mock_delta_table, patch( + "fabric_cli.commands.tables.fab_tables_schema.FabAuth" + ) as mock_auth: + + mock_auth_instance = MagicMock() + mock_auth_instance.get_access_token.return_value = "test_token_123" + mock_auth.return_value = mock_auth_instance + + mock_table_instance = MagicMock() + mock_arrow_schema = MagicMock() + mock_arrow_schema.to_json.return_value = json.dumps(mock_schema) + mock_table_instance.schema.return_value = mock_arrow_schema + mock_delta_table.return_value = mock_table_instance + + # Call function + result = fab_tables_schema._get_table_schema(args) + + # Verify DeltaTable was called with correct URI format + call_args = mock_delta_table.call_args + table_uri = call_args[0][0] + + # Verify ABFSS format + assert table_uri.startswith("abfss://workspace-guid-123@") + assert "lakehouse-guid-456" in table_uri + assert "Tables/my_table" in table_uri + + # Verify storage options + storage_options = call_args[1]["storage_options"] + assert storage_options["bearer_token"] == "test_token_123" + assert storage_options["use_fabric_endpoint"] == "true" + + # Verify result + assert isinstance(result, list) + assert len(result) == 1 From 3963efa9f2316acaa456074fd697453519076d4c Mon Sep 17 00:00:00 2001 From: Piotr Kontek Date: Thu, 7 May 2026 11:12:07 +0200 Subject: [PATCH 09/32] fix: Add deltalake dependency to development requirements --- requirements-dev.txt | 1 + 1 file changed, 1 insertion(+) 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 From 34c71416049ef8a9cfc688d77af21211b93d4944 Mon Sep 17 00:00:00 2001 From: Piotr Kontek Date: Fri, 15 May 2026 23:22:14 +0200 Subject: [PATCH 10/32] rename test_tables to test_tables_schema --- .vscode/settings.json | 11 ++++++++++- .../{test_tables.py => test_tables_schema.py} | 0 2 files changed, 10 insertions(+), 1 deletion(-) rename tests/test_commands/{test_tables.py => test_tables_schema.py} (100%) diff --git a/.vscode/settings.json b/.vscode/settings.json index e4470e31e..7133826c8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -21,5 +21,14 @@ "--lines-between-types", "0", "--remove-redundant-aliases" - ] + ], + "chat.agentSkillsLocations": { + ".agents/skills": true, + ".github/skills": true, + ".claude/skills": true, + "~/.agents/skills": true, + "~/.copilot/skills": true, + "~/.claude/skills": true, + "~/.vscode/extensions/synapsevscode.synapse-1.23.0/copilot/skills": true + } } \ No newline at end of file diff --git a/tests/test_commands/test_tables.py b/tests/test_commands/test_tables_schema.py similarity index 100% rename from tests/test_commands/test_tables.py rename to tests/test_commands/test_tables_schema.py From bce23dc25f812854441cca8baefd8ec2ad1078e4 Mon Sep 17 00:00:00 2001 From: Piotr Kontek Date: Fri, 15 May 2026 23:51:47 +0200 Subject: [PATCH 11/32] extract shared mock fixtures in test_tables_schema --- tests/test_commands/test_tables_schema.py | 322 +++++++--------------- 1 file changed, 96 insertions(+), 226 deletions(-) diff --git a/tests/test_commands/test_tables_schema.py b/tests/test_commands/test_tables_schema.py index 71e99fa9f..f99274430 100644 --- a/tests/test_commands/test_tables_schema.py +++ b/tests/test_commands/test_tables_schema.py @@ -16,9 +16,28 @@ class TestTablesSchemaUnit: """Unit tests for table schema command - direct function calls without VCR.""" - def test_get_table_schema_success(self): + @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.""" - # Setup args = Namespace( ws_id="test-ws-id", lakehouse_id="test-lakehouse-id", @@ -32,47 +51,26 @@ def test_get_table_schema_success(self): {"name": "name", "type": "string", "nullable": True, "metadata": {}}, ] } + self._make_delta_table_mock(mock_delta_table, json.dumps(mock_schema)) - # Mock DeltaTable - with patch( - "fabric_cli.commands.tables.fab_tables_schema.DeltaTable" - ) as mock_delta_table, patch( - "fabric_cli.commands.tables.fab_tables_schema.FabAuth" - ) as mock_auth: - - # Configure mocks - mock_auth_instance = MagicMock() - mock_auth_instance.get_access_token.return_value = "mock_token" - mock_auth.return_value = mock_auth_instance - - mock_table_instance = MagicMock() - mock_arrow_schema = MagicMock() - mock_arrow_schema.to_json.return_value = json.dumps(mock_schema) - mock_table_instance.schema.return_value = mock_arrow_schema - mock_delta_table.return_value = mock_table_instance - - # Call function - result = fab_tables_schema._get_table_schema(args) - - # Assert - 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" - - # Verify DeltaTable was called correctly - 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): + 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).""" - # Setup args = Namespace( ws_id="test-ws-id", lakehouse_id="test-lakehouse-id", @@ -85,40 +83,19 @@ def test_get_table_schema_with_explicit_schema_success(self): {"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" - # Mock DeltaTable - with patch( - "fabric_cli.commands.tables.fab_tables_schema.DeltaTable" - ) as mock_delta_table, patch( - "fabric_cli.commands.tables.fab_tables_schema.FabAuth" - ) as mock_auth: - - # Configure mocks - mock_auth_instance = MagicMock() - mock_auth_instance.get_access_token.return_value = "mock_token" - mock_auth.return_value = mock_auth_instance - - mock_table_instance = MagicMock() - mock_arrow_schema = MagicMock() - mock_arrow_schema.to_json.return_value = json.dumps(mock_schema) - mock_table_instance.schema.return_value = mock_arrow_schema - mock_delta_table.return_value = mock_table_instance - - # Call function - result = fab_tables_schema._get_table_schema(args) - - # Verify table URI includes schema path - call_args = mock_delta_table.call_args - assert "Tables/dbo/test_table" in call_args[0][0] - - # Assert result - assert isinstance(result, list) - assert len(result) == 1 - assert result[0]["name"] == "col1" - - def test_get_table_schema_table_not_found_error(self): + def test_get_table_schema_table_not_found_error(self, mock_auth, mock_delta_table): """Test TableNotFoundError is mapped to FabricCLIError.""" - # Setup args = Namespace( ws_id="test-ws-id", lakehouse_id="test-lakehouse-id", @@ -126,30 +103,16 @@ def test_get_table_schema_table_not_found_error(self): schema=None, ) - # Mock DeltaTable to raise TableNotFoundError - with patch( - "fabric_cli.commands.tables.fab_tables_schema.DeltaTable" - ) as mock_delta_table, patch( - "fabric_cli.commands.tables.fab_tables_schema.FabAuth" - ) as mock_auth: + mock_delta_table.side_effect = TableNotFoundError("Table not found") - mock_auth_instance = MagicMock() - mock_auth_instance.get_access_token.return_value = "mock_token" - mock_auth.return_value = mock_auth_instance + with pytest.raises(FabricCLIError) as exc_info: + fab_tables_schema._get_table_schema(args) - mock_delta_table.side_effect = TableNotFoundError("Table not found") + assert exc_info.value.status_code == fab_constant.ERROR_INVALID_DELTA_TABLE + assert "Failed to extract the table schema" in exc_info.value.message - # Call function and expect error - with pytest.raises(FabricCLIError) as exc_info: - fab_tables_schema._get_table_schema(args) - - # Assert error details - 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_delta_error(self): + def test_get_table_schema_delta_error(self, mock_auth, mock_delta_table): """Test generic DeltaError is mapped to FabricCLIError.""" - # Setup args = Namespace( ws_id="test-ws-id", lakehouse_id="test-lakehouse-id", @@ -157,30 +120,16 @@ def test_get_table_schema_delta_error(self): schema=None, ) - # Mock DeltaTable to raise DeltaError - with patch( - "fabric_cli.commands.tables.fab_tables_schema.DeltaTable" - ) as mock_delta_table, patch( - "fabric_cli.commands.tables.fab_tables_schema.FabAuth" - ) as mock_auth: - - mock_auth_instance = MagicMock() - mock_auth_instance.get_access_token.return_value = "mock_token" - mock_auth.return_value = mock_auth_instance + mock_delta_table.side_effect = DeltaError("Generic delta error") - mock_delta_table.side_effect = DeltaError("Generic delta error") + with pytest.raises(FabricCLIError) as exc_info: + fab_tables_schema._get_table_schema(args) - # Call function and expect 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 - # Assert error details - 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): + def test_get_table_schema_invalid_json_error(self, mock_auth, mock_delta_table): """Test invalid JSON in schema is handled.""" - # Setup args = Namespace( ws_id="test-ws-id", lakehouse_id="test-lakehouse-id", @@ -188,34 +137,16 @@ def test_get_table_schema_invalid_json_error(self): schema=None, ) - # Mock DeltaTable to return invalid JSON - with patch( - "fabric_cli.commands.tables.fab_tables_schema.DeltaTable" - ) as mock_delta_table, patch( - "fabric_cli.commands.tables.fab_tables_schema.FabAuth" - ) as mock_auth: - - mock_auth_instance = MagicMock() - mock_auth_instance.get_access_token.return_value = "mock_token" - mock_auth.return_value = mock_auth_instance + self._make_delta_table_mock(mock_delta_table, "invalid json {") - mock_table_instance = MagicMock() - mock_arrow_schema = MagicMock() - mock_arrow_schema.to_json.return_value = "invalid json {" - mock_table_instance.schema.return_value = mock_arrow_schema - mock_delta_table.return_value = mock_table_instance + with pytest.raises(FabricCLIError) as exc_info: + fab_tables_schema._get_table_schema(args) - # Call function and expect 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 - # Assert error details - 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): + def test_get_table_schema_missing_fields_key(self, mock_auth, mock_delta_table): """Test schema JSON without 'fields' key is handled.""" - # Setup args = Namespace( ws_id="test-ws-id", lakehouse_id="test-lakehouse-id", @@ -223,36 +154,16 @@ def test_get_table_schema_missing_fields_key(self): schema=None, ) - mock_schema = {"some_other_key": "value"} - - # Mock DeltaTable - with patch( - "fabric_cli.commands.tables.fab_tables_schema.DeltaTable" - ) as mock_delta_table, patch( - "fabric_cli.commands.tables.fab_tables_schema.FabAuth" - ) as mock_auth: - - mock_auth_instance = MagicMock() - mock_auth_instance.get_access_token.return_value = "mock_token" - mock_auth.return_value = mock_auth_instance + self._make_delta_table_mock(mock_delta_table, json.dumps({"some_other_key": "value"})) - mock_table_instance = MagicMock() - mock_arrow_schema = MagicMock() - mock_arrow_schema.to_json.return_value = json.dumps(mock_schema) - mock_table_instance.schema.return_value = mock_arrow_schema - mock_delta_table.return_value = mock_table_instance + with pytest.raises(FabricCLIError) as exc_info: + fab_tables_schema._get_table_schema(args) - # Call function and expect 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 - # Assert error details - 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): + 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.""" - # Setup args = Namespace( ws_id="test-ws-id", lakehouse_id="test-lakehouse-id", @@ -260,36 +171,16 @@ def test_get_table_schema_fields_not_list(self): schema=None, ) - mock_schema = {"fields": "not a list"} - - # Mock DeltaTable - with patch( - "fabric_cli.commands.tables.fab_tables_schema.DeltaTable" - ) as mock_delta_table, patch( - "fabric_cli.commands.tables.fab_tables_schema.FabAuth" - ) as mock_auth: - - mock_auth_instance = MagicMock() - mock_auth_instance.get_access_token.return_value = "mock_token" - mock_auth.return_value = mock_auth_instance + self._make_delta_table_mock(mock_delta_table, json.dumps({"fields": "not a list"})) - mock_table_instance = MagicMock() - mock_arrow_schema = MagicMock() - mock_arrow_schema.to_json.return_value = json.dumps(mock_schema) - mock_table_instance.schema.return_value = mock_arrow_schema - mock_delta_table.return_value = mock_table_instance + with pytest.raises(FabricCLIError) as exc_info: + fab_tables_schema._get_table_schema(args) - # Call function and expect 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 - # Assert error details - 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): + 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.""" - # Setup args = Namespace( ws_id="workspace-guid-123", lakehouse_id="lakehouse-guid-456", @@ -302,41 +193,20 @@ def test_get_table_schema_verifies_abfss_uri_format(self): {"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" - # Mock DeltaTable - with patch( - "fabric_cli.commands.tables.fab_tables_schema.DeltaTable" - ) as mock_delta_table, patch( - "fabric_cli.commands.tables.fab_tables_schema.FabAuth" - ) as mock_auth: - - mock_auth_instance = MagicMock() - mock_auth_instance.get_access_token.return_value = "test_token_123" - mock_auth.return_value = mock_auth_instance - - mock_table_instance = MagicMock() - mock_arrow_schema = MagicMock() - mock_arrow_schema.to_json.return_value = json.dumps(mock_schema) - mock_table_instance.schema.return_value = mock_arrow_schema - mock_delta_table.return_value = mock_table_instance - - # Call function - result = fab_tables_schema._get_table_schema(args) - - # Verify DeltaTable was called with correct URI format - call_args = mock_delta_table.call_args - table_uri = call_args[0][0] - - # Verify ABFSS format - assert table_uri.startswith("abfss://workspace-guid-123@") - assert "lakehouse-guid-456" in table_uri - assert "Tables/my_table" in table_uri - - # Verify storage options - storage_options = call_args[1]["storage_options"] - assert storage_options["bearer_token"] == "test_token_123" - assert storage_options["use_fabric_endpoint"] == "true" - - # Verify result - assert isinstance(result, list) - assert len(result) == 1 + assert isinstance(result, list) + assert len(result) == 1 From a4c4ba7cce27201c529935d7c91a80fb2f6c3b25 Mon Sep 17 00:00:00 2001 From: Piotr Kontek Date: Fri, 15 May 2026 23:55:57 +0200 Subject: [PATCH 12/32] combine delta error tests into parametrized test case --- tests/test_commands/test_tables_schema.py | 24 ++++------------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/tests/test_commands/test_tables_schema.py b/tests/test_commands/test_tables_schema.py index f99274430..8fb735e3f 100644 --- a/tests/test_commands/test_tables_schema.py +++ b/tests/test_commands/test_tables_schema.py @@ -94,25 +94,9 @@ def test_get_table_schema_with_explicit_schema_success(self, mock_auth, mock_del assert len(result) == 1 assert result[0]["name"] == "col1" - def test_get_table_schema_table_not_found_error(self, mock_auth, mock_delta_table): - """Test TableNotFoundError is mapped to FabricCLIError.""" - args = Namespace( - ws_id="test-ws-id", - lakehouse_id="test-lakehouse-id", - table_name="nonexistent", - schema=None, - ) - - mock_delta_table.side_effect = TableNotFoundError("Table not found") - - 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_delta_error(self, mock_auth, mock_delta_table): - """Test generic DeltaError is mapped to FabricCLIError.""" + @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", @@ -120,7 +104,7 @@ def test_get_table_schema_delta_error(self, mock_auth, mock_delta_table): schema=None, ) - mock_delta_table.side_effect = DeltaError("Generic delta error") + mock_delta_table.side_effect = error_cls("error") with pytest.raises(FabricCLIError) as exc_info: fab_tables_schema._get_table_schema(args) From 55c35156ffc4a733bb692b3d9bff2edfe8fc2aef Mon Sep 17 00:00:00 2001 From: pkontek Date: Sat, 16 May 2026 00:00:59 +0200 Subject: [PATCH 13/32] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .vscode/settings.json | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 7133826c8..e4470e31e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -21,14 +21,5 @@ "--lines-between-types", "0", "--remove-redundant-aliases" - ], - "chat.agentSkillsLocations": { - ".agents/skills": true, - ".github/skills": true, - ".claude/skills": true, - "~/.agents/skills": true, - "~/.copilot/skills": true, - "~/.claude/skills": true, - "~/.vscode/extensions/synapsevscode.synapse-1.23.0/copilot/skills": true - } + ] } \ No newline at end of file From f206e12232a16033e0c4f9365648da09397dae78 Mon Sep 17 00:00:00 2001 From: Piotr Kontek Date: Sat, 16 May 2026 00:07:40 +0200 Subject: [PATCH 14/32] feat: add integration tests for table schema command and mock API responses --- .../test_tables_schema/class_setup.yaml | 180 ++++++++++++ .../test_table_schema_success.yaml | 258 ++++++++++++++++++ tests/test_commands/test_tables_schema.py | 36 +++ 3 files changed, 474 insertions(+) create mode 100644 tests/test_commands/recordings/test_commands/test_tables_schema/class_setup.yaml create mode 100644 tests/test_commands/recordings/test_commands/test_tables_schema/test_table_schema_success.yaml 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 index 8fb735e3f..3356d5baf 100644 --- a/tests/test_commands/test_tables_schema.py +++ b/tests/test_commands/test_tables_schema.py @@ -11,6 +11,9 @@ 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: @@ -194,3 +197,36 @@ def test_get_table_schema_verifies_abfss_uri_format(self, mock_auth, mock_delta_ 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) From 4cda700ddf6022747c78f5de33faa807337c57de Mon Sep 17 00:00:00 2001 From: Piotr Kontek Date: Sat, 16 May 2026 00:12:41 +0200 Subject: [PATCH 15/32] fix: improve error handling in table schema extraction --- src/fabric_cli/commands/tables/fab_tables_schema.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/fabric_cli/commands/tables/fab_tables_schema.py b/src/fabric_cli/commands/tables/fab_tables_schema.py index c015fc3b9..7acc91705 100644 --- a/src/fabric_cli/commands/tables/fab_tables_schema.py +++ b/src/fabric_cli/commands/tables/fab_tables_schema.py @@ -45,8 +45,8 @@ def _get_table_schema(args: Namespace) -> list[dict]: 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, TypeError, KeyError, ValueError): + except (DeltaError, json.JSONDecodeError, ValueError) as exc: raise FabricCLIError( - "Failed to extract the table schema. Please ensure the path points to a valid Delta table", + 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 From c954a2a6f02fc0f5bb7255f58a7f41b6596aa1c0 Mon Sep 17 00:00:00 2001 From: Piotr Kontek Date: Sun, 17 May 2026 13:23:44 +0200 Subject: [PATCH 16/32] fix: ensure access token is validated before schema extraction --- src/fabric_cli/commands/tables/fab_tables_schema.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/fabric_cli/commands/tables/fab_tables_schema.py b/src/fabric_cli/commands/tables/fab_tables_schema.py index 7acc91705..bda99d66e 100644 --- a/src/fabric_cli/commands/tables/fab_tables_schema.py +++ b/src/fabric_cli/commands/tables/fab_tables_schema.py @@ -21,6 +21,11 @@ def exec_command(args: Namespace) -> None: 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 obtain access token.", + fab_constant.ERROR_AUTHENTICATION_FAILED, + ) if args.schema: local_path = f"Tables/{args.schema}/{args.table_name}" else: From 0ba9da903ad646027829d1ed5851ae100397924d Mon Sep 17 00:00:00 2001 From: Piotr Kontek Date: Wed, 17 Jun 2026 17:02:55 +0000 Subject: [PATCH 17/32] fix: correct URL formatting in do_request function --- src/fabric_cli/client/fab_api_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fabric_cli/client/fab_api_client.py b/src/fabric_cli/client/fab_api_client.py index 07f198385..d48fd9e8b 100644 --- a/src/fabric_cli/client/fab_api_client.py +++ b/src/fabric_cli/client/fab_api_client.py @@ -103,7 +103,7 @@ def do_request( request_params["continuationToken"] = continuation_token # Build url - url = f"https://{url}/{uri}" + url = f"https://{url}/{uri.lstrip('/')}" if request_params: url += f"?{requests.compat.urlencode(request_params)}" From c0158640232961c2f7d6faf65e866c27b7284405 Mon Sep 17 00:00:00 2001 From: Piotr Kontek Date: Thu, 18 Jun 2026 09:28:47 +0000 Subject: [PATCH 18/32] fix: redact internal exception details from table schema error message The exc str could expose abfss:// URIs (workspace/item GUIDs), Rust delta-rs diagnostics, and storage-backend details in user-facing output. Strip it from the message while preserving the cause via `from exc`. --- src/fabric_cli/commands/tables/fab_tables_schema.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fabric_cli/commands/tables/fab_tables_schema.py b/src/fabric_cli/commands/tables/fab_tables_schema.py index bda99d66e..0cd9afaaa 100644 --- a/src/fabric_cli/commands/tables/fab_tables_schema.py +++ b/src/fabric_cli/commands/tables/fab_tables_schema.py @@ -52,6 +52,6 @@ def _get_table_schema(args: Namespace) -> list[dict]: 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}", + "Failed to extract the table schema. Please ensure the path points to a valid Delta table.", fab_constant.ERROR_INVALID_DELTA_TABLE, ) from exc From b6da0e7fb49a22e4169a48a2a6d3d11184b99233 Mon Sep 17 00:00:00 2001 From: Piotr Kontek Date: Thu, 18 Jun 2026 09:34:52 +0000 Subject: [PATCH 19/32] refactor: extract DeltaTable construction into fab_delta_client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move token acquisition, abfss:// URI building, storage_options assembly, and DeltaError → FabricCLIError mapping out of the command layer into a new centralised client (fab_delta_client.py), consistent with the fab_api_*.py pattern. The command now only resolves local_path and delegates to delta_client.get_table_schema(). fab_delta_client provides a single seam for future retry, timeout, and telemetry hooks. --- src/fabric_cli/client/fab_delta_client.py | 50 +++++++++++++++++++ .../commands/tables/fab_tables_schema.py | 40 +-------------- 2 files changed, 52 insertions(+), 38 deletions(-) create mode 100644 src/fabric_cli/client/fab_delta_client.py diff --git a/src/fabric_cli/client/fab_delta_client.py b/src/fabric_cli/client/fab_delta_client.py new file mode 100644 index 000000000..27cc8e68f --- /dev/null +++ b/src/fabric_cli/client/fab_delta_client.py @@ -0,0 +1,50 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import json +from argparse import Namespace + +from deltalake import DeltaTable +from deltalake.exceptions import DeltaError + +from fabric_cli.core import fab_constant +from fabric_cli.core.fab_auth import FabAuth +from fabric_cli.core.fab_exceptions import FabricCLIError + + +def get_table_schema(args: Namespace, local_path: str) -> list[dict]: + """Return schema fields for a Delta table on OneLake. + + Single seam for DeltaTable construction: owns URI building, + storage_options assembly, token acquisition, and exception mapping. + """ + token = FabAuth().get_access_token(fab_constant.SCOPE_ONELAKE_DEFAULT) + if token is None: + raise FabricCLIError( + "Failed to obtain access token.", + fab_constant.ERROR_AUTHENTICATION_FAILED, + ) + + 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_dict = json.loads(table.schema().to_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( + "Failed to extract the table schema. Please ensure the path points to a valid Delta table.", + fab_constant.ERROR_INVALID_DELTA_TABLE, + ) from exc diff --git a/src/fabric_cli/commands/tables/fab_tables_schema.py b/src/fabric_cli/commands/tables/fab_tables_schema.py index 0cd9afaaa..9c1298189 100644 --- a/src/fabric_cli/commands/tables/fab_tables_schema.py +++ b/src/fabric_cli/commands/tables/fab_tables_schema.py @@ -1,15 +1,9 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -import json from argparse import Namespace -from deltalake import DeltaTable -from deltalake.exceptions import DeltaError - -from fabric_cli.core import fab_constant -from fabric_cli.core.fab_auth import FabAuth -from fabric_cli.core.fab_exceptions import FabricCLIError +from fabric_cli.client import fab_delta_client as delta_client from fabric_cli.utils import fab_ui @@ -20,38 +14,8 @@ def exec_command(args: Namespace) -> None: 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 obtain access token.", - fab_constant.ERROR_AUTHENTICATION_FAILED, - ) 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( - "Failed to extract the table schema. Please ensure the path points to a valid Delta table.", - fab_constant.ERROR_INVALID_DELTA_TABLE, - ) from exc + return delta_client.get_table_schema(args, local_path) From 7996c2e36bb0eecc88c284e77d84aeb1f5df9e39 Mon Sep 17 00:00:00 2001 From: Piotr Kontek Date: Thu, 18 Jun 2026 09:44:33 +0000 Subject: [PATCH 20/32] fix: use context.local_path instead of reconstructing table path in schema command Populate args.table_local_path in add_table_props_to_args so commands receive the already-resolved path from the OneLake context. Removes the manual f"Tables/..." reconstruction from fab_tables_schema. --- src/fabric_cli/commands/tables/fab_tables_schema.py | 6 +----- src/fabric_cli/utils/fab_cmd_table_utils.py | 1 + 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/fabric_cli/commands/tables/fab_tables_schema.py b/src/fabric_cli/commands/tables/fab_tables_schema.py index 9c1298189..6f2132358 100644 --- a/src/fabric_cli/commands/tables/fab_tables_schema.py +++ b/src/fabric_cli/commands/tables/fab_tables_schema.py @@ -14,8 +14,4 @@ def exec_command(args: Namespace) -> None: def _get_table_schema(args: Namespace) -> list[dict]: - if args.schema: - local_path = f"Tables/{args.schema}/{args.table_name}" - else: - local_path = f"Tables/{args.table_name}" - return delta_client.get_table_schema(args, local_path) + return delta_client.get_table_schema(args, args.table_local_path) diff --git a/src/fabric_cli/utils/fab_cmd_table_utils.py b/src/fabric_cli/utils/fab_cmd_table_utils.py index 3b00fb6af..7876e6d1d 100644 --- a/src/fabric_cli/utils/fab_cmd_table_utils.py +++ b/src/fabric_cli/utils/fab_cmd_table_utils.py @@ -23,6 +23,7 @@ def add_table_props_to_args(args: Any, context: OneLakeItem) -> None: table_path = context.local_path.split("/") args.table_name = table_path[-1] args.schema = table_path[-2] if len(table_path) == 3 else None + args.table_local_path = context.local_path def convert_hours_to_dhhmmss(hours: int) -> str: From 6d4ac61b3147991ab6bcd25daeb3f8535d0aec57 Mon Sep 17 00:00:00 2001 From: Piotr Kontek Date: Thu, 18 Jun 2026 09:58:50 +0000 Subject: [PATCH 21/32] fix: strip .Shortcut suffix from table_local_path in add_table_props_to_args When OneLake path resolution falls back to the .Shortcut form, the suffix would propagate into the abfss:// URI and cause DeltaTable to fail. Strip it upstream in add_table_props_to_args via remove_dot_suffix, consistent with how other OneLake commands handle it. Also fix unit test patch paths and args after fab_delta_client extraction. --- src/fabric_cli/utils/fab_cmd_table_utils.py | 3 +- tests/test_commands/test_tables_schema.py | 80 ++++++++++++++++----- 2 files changed, 64 insertions(+), 19 deletions(-) diff --git a/src/fabric_cli/utils/fab_cmd_table_utils.py b/src/fabric_cli/utils/fab_cmd_table_utils.py index 7876e6d1d..610457e04 100644 --- a/src/fabric_cli/utils/fab_cmd_table_utils.py +++ b/src/fabric_cli/utils/fab_cmd_table_utils.py @@ -7,6 +7,7 @@ from fabric_cli.core.fab_exceptions import FabricCLIError from fabric_cli.core.hiearchy.fab_onelake_element import OneLakeItem from fabric_cli.errors import ErrorMessages +from fabric_cli.utils import fab_util as utils def add_table_props_to_args(args: Any, context: OneLakeItem) -> None: @@ -23,7 +24,7 @@ def add_table_props_to_args(args: Any, context: OneLakeItem) -> None: table_path = context.local_path.split("/") args.table_name = table_path[-1] args.schema = table_path[-2] if len(table_path) == 3 else None - args.table_local_path = context.local_path + args.table_local_path = utils.remove_dot_suffix(context.local_path) def convert_hours_to_dhhmmss(hours: int) -> str: diff --git a/tests/test_commands/test_tables_schema.py b/tests/test_commands/test_tables_schema.py index 3356d5baf..19c51d693 100644 --- a/tests/test_commands/test_tables_schema.py +++ b/tests/test_commands/test_tables_schema.py @@ -12,16 +12,19 @@ from fabric_cli.core import fab_constant from fabric_cli.core.fab_exceptions import FabricCLIError from fabric_cli.core.fab_types import ItemType +from fabric_cli.utils import fab_cmd_table_utils as utils_table from tests.conftest import mock_questionary_print # noqa: F401 from tests.test_commands.commands_parser import CLIExecutor +_DELTA_CLIENT = "fabric_cli.client.fab_delta_client" + 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: + with patch(f"{_DELTA_CLIENT}.FabAuth") as mock: instance = MagicMock() instance.get_access_token.return_value = "mock_token" mock.return_value = instance @@ -29,7 +32,7 @@ def mock_auth(self): @pytest.fixture def mock_delta_table(self): - with patch("fabric_cli.commands.tables.fab_tables_schema.DeltaTable") as mock: + with patch(f"{_DELTA_CLIENT}.DeltaTable") as mock: yield mock def _make_delta_table_mock(self, mock_delta_table, schema_json): @@ -44,8 +47,7 @@ def test_get_table_schema_success(self, mock_auth, mock_delta_table): args = Namespace( ws_id="test-ws-id", lakehouse_id="test-lakehouse-id", - table_name="test_table", - schema=None, + table_local_path="Tables/test_table", ) mock_schema = { @@ -77,8 +79,7 @@ def test_get_table_schema_with_explicit_schema_success(self, mock_auth, mock_del args = Namespace( ws_id="test-ws-id", lakehouse_id="test-lakehouse-id", - table_name="test_table", - schema="dbo", + table_local_path="Tables/dbo/test_table", ) mock_schema = { @@ -103,8 +104,7 @@ def test_get_table_schema_delta_exceptions(self, mock_auth, mock_delta_table, er args = Namespace( ws_id="test-ws-id", lakehouse_id="test-lakehouse-id", - table_name="test_table", - schema=None, + table_local_path="Tables/test_table", ) mock_delta_table.side_effect = error_cls("error") @@ -120,8 +120,7 @@ def test_get_table_schema_invalid_json_error(self, mock_auth, mock_delta_table): args = Namespace( ws_id="test-ws-id", lakehouse_id="test-lakehouse-id", - table_name="test_table", - schema=None, + table_local_path="Tables/test_table", ) self._make_delta_table_mock(mock_delta_table, "invalid json {") @@ -137,8 +136,7 @@ def test_get_table_schema_missing_fields_key(self, mock_auth, mock_delta_table): args = Namespace( ws_id="test-ws-id", lakehouse_id="test-lakehouse-id", - table_name="test_table", - schema=None, + table_local_path="Tables/test_table", ) self._make_delta_table_mock(mock_delta_table, json.dumps({"some_other_key": "value"})) @@ -154,8 +152,7 @@ def test_get_table_schema_fields_not_list(self, mock_auth, mock_delta_table): args = Namespace( ws_id="test-ws-id", lakehouse_id="test-lakehouse-id", - table_name="test_table", - schema=None, + table_local_path="Tables/test_table", ) self._make_delta_table_mock(mock_delta_table, json.dumps({"fields": "not a list"})) @@ -171,8 +168,7 @@ def test_get_table_schema_verifies_abfss_uri_format(self, mock_auth, mock_delta_ args = Namespace( ws_id="workspace-guid-123", lakehouse_id="lakehouse-guid-456", - table_name="my_table", - schema=None, + table_local_path="Tables/my_table", ) mock_schema = { @@ -199,6 +195,54 @@ def test_get_table_schema_verifies_abfss_uri_format(self, mock_auth, mock_delta_ assert len(result) == 1 +class TestAddTablePropsToArgs: + """Tests for add_table_props_to_args normalization.""" + + def _make_context(self, local_path: str) -> MagicMock: + from fabric_cli.core.hiearchy.fab_onelake_element import OneLakeItem + + context = MagicMock() + context.__class__ = OneLakeItem # make isinstance(context, OneLakeItem) pass + context.local_path = local_path + return context + + def test_shortcut_suffix_stripped_from_table_local_path(self): + """Regression: .Shortcut must not appear in args.table_local_path.""" + args = Namespace() + context = self._make_context("Tables/my_table.Shortcut") + + utils_table.add_table_props_to_args(args, context) + + assert ".Shortcut" not in args.table_local_path + assert args.table_local_path == "Tables/my_table" + + def test_shortcut_suffix_stripped_from_schema_path(self): + """Regression: .Shortcut must not appear anywhere in table_local_path for schema tables.""" + args = Namespace() + context = self._make_context("Tables/dbo/my_table.Shortcut") + + utils_table.add_table_props_to_args(args, context) + + assert ".Shortcut" not in args.table_local_path + assert args.table_local_path == "Tables/dbo/my_table" + + def test_normal_path_unchanged(self): + args = Namespace() + context = self._make_context("Tables/my_table") + + utils_table.add_table_props_to_args(args, context) + + assert args.table_local_path == "Tables/my_table" + + def test_schema_path_unchanged(self): + args = Namespace() + context = self._make_context("Tables/dbo/my_table") + + utils_table.add_table_props_to_args(args, context) + + assert args.table_local_path == "Tables/dbo/my_table" + + class TestTablesSchemaIntegration: """Integration tests for table schema command - validates full dispatch stack.""" @@ -213,9 +257,9 @@ def test_table_schema_success( mock_questionary_print.reset_mock() with patch( - "fabric_cli.commands.tables.fab_tables_schema.DeltaTable" + f"{_DELTA_CLIENT}.DeltaTable" ) as mock_dt, patch( - "fabric_cli.commands.tables.fab_tables_schema.FabAuth" + f"{_DELTA_CLIENT}.FabAuth" ) as mock_auth: mock_auth.return_value.get_access_token.return_value = "mock_token" mock_table = MagicMock() From ed998af31adcb5f5348099125d91da1034e75c5c Mon Sep 17 00:00:00 2001 From: Piotr Kontek Date: Thu, 18 Jun 2026 10:33:47 +0000 Subject: [PATCH 22/32] fix: reject item types without Delta-compatible Tables/ in schema command The allowlist lives in fab_delta_client next to the URI construction that makes the Tables/ assumption. Validation is skipped when item_type is absent so unit tests that construct args directly are unaffected. --- src/fabric_cli/client/fab_delta_client.py | 24 +++++++++++-- src/fabric_cli/errors/table.py | 9 ++++- tests/test_commands/test_tables_schema.py | 43 +++++++++++++++++++++++ 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/src/fabric_cli/client/fab_delta_client.py b/src/fabric_cli/client/fab_delta_client.py index 27cc8e68f..5bfde25fd 100644 --- a/src/fabric_cli/client/fab_delta_client.py +++ b/src/fabric_cli/client/fab_delta_client.py @@ -10,14 +10,34 @@ from fabric_cli.core import fab_constant from fabric_cli.core.fab_auth import FabAuth from fabric_cli.core.fab_exceptions import FabricCLIError +from fabric_cli.errors import ErrorMessages + +# Item types whose OneLake Tables/ folder contains standard Delta tables. +# SemanticModel is explicitly excluded: its Tables/ entries are columnar +# semantic-model representations, not Delta-compatible parquet. +_DELTA_SUPPORTED_ITEM_TYPES: frozenset[str] = frozenset({ + "Lakehouse", + "Warehouse", + "KQLDatabase", + "MirroredDatabase", + "SQLDatabase", +}) def get_table_schema(args: Namespace, local_path: str) -> list[dict]: """Return schema fields for a Delta table on OneLake. - Single seam for DeltaTable construction: owns URI building, - storage_options assembly, token acquisition, and exception mapping. + Single seam for DeltaTable construction: owns item-type validation, + URI building, storage_options assembly, token acquisition, and + exception mapping. """ + item_type = getattr(args, "item_type", None) + if item_type is not None and item_type not in _DELTA_SUPPORTED_ITEM_TYPES: + raise FabricCLIError( + ErrorMessages.Table.unsupported_item_type_for_delta(item_type), + fab_constant.ERROR_INVALID_ITEM_TYPE, + ) + token = FabAuth().get_access_token(fab_constant.SCOPE_ONELAKE_DEFAULT) if token is None: raise FabricCLIError( diff --git a/src/fabric_cli/errors/table.py b/src/fabric_cli/errors/table.py index 46e4f8773..2f2b25f4c 100644 --- a/src/fabric_cli/errors/table.py +++ b/src/fabric_cli/errors/table.py @@ -13,4 +13,11 @@ def invalid_format_argument(part: str) -> str: @staticmethod def invalid_key(key: str, allowed_keys: str) -> str: - return f"Invalid key: '{key}'. Allowed keys are: {allowed_keys}" \ No newline at end of file + return f"Invalid key: '{key}'. Allowed keys are: {allowed_keys}" + + @staticmethod + def unsupported_item_type_for_delta(item_type: str) -> str: + return ( + f"'{item_type}' does not expose Delta tables under Tables/. " + "Delta table schema is supported for: Lakehouse, Warehouse, KQLDatabase, MirroredDatabase, SQLDatabase." + ) \ No newline at end of file diff --git a/tests/test_commands/test_tables_schema.py b/tests/test_commands/test_tables_schema.py index 19c51d693..1e6351978 100644 --- a/tests/test_commands/test_tables_schema.py +++ b/tests/test_commands/test_tables_schema.py @@ -195,6 +195,49 @@ def test_get_table_schema_verifies_abfss_uri_format(self, mock_auth, mock_delta_ assert len(result) == 1 +class TestDeltaItemTypeValidation: + """Regression tests: only item types with Delta-compatible Tables/ are accepted.""" + + @pytest.fixture + def mock_auth(self): + with patch(f"{_DELTA_CLIENT}.FabAuth") as mock: + mock.return_value.get_access_token.return_value = "mock_token" + yield mock + + @pytest.fixture + def mock_delta_table(self): + with patch(f"{_DELTA_CLIENT}.DeltaTable") as mock: + mock_schema = MagicMock() + mock_schema.to_json.return_value = json.dumps({"fields": []}) + mock.return_value.schema.return_value = mock_schema + yield mock + + @pytest.mark.parametrize("item_type", [ + "Lakehouse", "Warehouse", "KQLDatabase", "MirroredDatabase", "SQLDatabase", + ]) + def test_supported_item_types_pass_validation(self, mock_auth, mock_delta_table, item_type): + args = Namespace( + ws_id="ws", lakehouse_id="lh", table_local_path="Tables/t", item_type=item_type + ) + # should not raise + fab_tables_schema._get_table_schema(args) + + def test_semantic_model_raises_clear_error(self, mock_auth, mock_delta_table): + args = Namespace( + ws_id="ws", lakehouse_id="lh", table_local_path="Tables/t", item_type="SemanticModel" + ) + with pytest.raises(FabricCLIError) as exc_info: + fab_tables_schema._get_table_schema(args) + assert exc_info.value.status_code == fab_constant.ERROR_INVALID_ITEM_TYPE + assert "SemanticModel" in exc_info.value.message + assert "Delta" in exc_info.value.message + + def test_missing_item_type_does_not_raise(self, mock_auth, mock_delta_table): + """item_type is absent when _get_table_schema is called directly in unit tests.""" + args = Namespace(ws_id="ws", lakehouse_id="lh", table_local_path="Tables/t") + fab_tables_schema._get_table_schema(args) + + class TestAddTablePropsToArgs: """Tests for add_table_props_to_args normalization.""" From 18df70730c3781b8fc2d0e4a3770dfd8c6a96fb4 Mon Sep 17 00:00:00 2001 From: Piotr Kontek Date: Thu, 18 Jun 2026 11:04:14 +0000 Subject: [PATCH 23/32] test: lock delta-rs schema serialisation contract for complex types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add test_complex_schema_field_contract asserting the exact JSON shape returned by DeltaTable.schema().to_json() for long, decimal, timestamp, map, and nested struct fields. Documents the non-obvious mappings (int64→"long", decimal128→"decimal(10,2)", timestamp→"timestamp_ntz") so format regressions are caught before they silently break scripts that pipe --output_format json. --- tests/test_commands/test_tables_schema.py | 79 +++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/tests/test_commands/test_tables_schema.py b/tests/test_commands/test_tables_schema.py index 1e6351978..ddcb86121 100644 --- a/tests/test_commands/test_tables_schema.py +++ b/tests/test_commands/test_tables_schema.py @@ -163,6 +163,85 @@ def test_get_table_schema_fields_not_list(self, mock_auth, mock_delta_table): 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_complex_schema_field_contract(self, mock_auth, mock_delta_table): + """Lock the exact JSON shape returned for complex Delta types. + + delta-rs serialises Arrow → Delta-protocol JSON via Schema.to_json(). + The mapping below was validated against the installed deltalake wheel + and must be stable for users who pipe --output_format json into scripts. + + Verified mappings: + pyarrow int64 → "long" (NOT "integer") + pyarrow decimal128 → "decimal(10,2)" (compact string, NOT an object) + pyarrow timestamp('us')→ "timestamp_ntz" (NOT "timestamp") + map / struct → nested objects with keyType/valueType/fields + """ + complex_schema_json = { + "type": "struct", + "fields": [ + {"name": "id", "type": "long", "nullable": False, "metadata": {}}, + {"name": "price", "type": "decimal(10,2)", "nullable": True, "metadata": {}}, + {"name": "created_at", "type": "timestamp_ntz", "nullable": True, "metadata": {}}, + { + "name": "tags", + "type": { + "type": "map", + "keyType": "string", + "valueType": "string", + "valueContainsNull": True, + }, + "nullable": True, + "metadata": {}, + }, + { + "name": "address", + "type": { + "type": "struct", + "fields": [ + {"name": "street", "type": "string", "nullable": True, "metadata": {}}, + {"name": "city", "type": "string", "nullable": True, "metadata": {}}, + ], + }, + "nullable": True, + "metadata": {}, + }, + ], + } + args = Namespace( + ws_id="ws", lakehouse_id="lh", table_local_path="Tables/complex_table" + ) + self._make_delta_table_mock(mock_delta_table, json.dumps(complex_schema_json)) + + fields = fab_tables_schema._get_table_schema(args) + + assert len(fields) == 5 + + assert fields[0] == {"name": "id", "type": "long", "nullable": False, "metadata": {}} + + assert fields[1] == {"name": "price", "type": "decimal(10,2)", "nullable": True, "metadata": {}} + + assert fields[2] == {"name": "created_at", "type": "timestamp_ntz", "nullable": True, "metadata": {}} + + assert fields[3] == { + "name": "tags", + "type": {"type": "map", "keyType": "string", "valueType": "string", "valueContainsNull": True}, + "nullable": True, + "metadata": {}, + } + + assert fields[4] == { + "name": "address", + "type": { + "type": "struct", + "fields": [ + {"name": "street", "type": "string", "nullable": True, "metadata": {}}, + {"name": "city", "type": "string", "nullable": True, "metadata": {}}, + ], + }, + "nullable": True, + "metadata": {}, + } + 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( From ceba9fc574bf84b854157633edb4afc9ff024a91 Mon Sep 17 00:00:00 2001 From: Piotr Kontek Date: Thu, 18 Jun 2026 11:41:55 +0000 Subject: [PATCH 24/32] fix: use ErrorMessages.Auth.access_token_failed() in fab_delta_client --- src/fabric_cli/client/fab_delta_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fabric_cli/client/fab_delta_client.py b/src/fabric_cli/client/fab_delta_client.py index 5bfde25fd..40445e60e 100644 --- a/src/fabric_cli/client/fab_delta_client.py +++ b/src/fabric_cli/client/fab_delta_client.py @@ -41,7 +41,7 @@ def get_table_schema(args: Namespace, local_path: str) -> list[dict]: token = FabAuth().get_access_token(fab_constant.SCOPE_ONELAKE_DEFAULT) if token is None: raise FabricCLIError( - "Failed to obtain access token.", + ErrorMessages.Auth.access_token_failed(), fab_constant.ERROR_AUTHENTICATION_FAILED, ) From ee79ee5316603a3f1e8ed982e7a80aaf36d18a7d Mon Sep 17 00:00:00 2001 From: Piotr Kontek Date: Thu, 18 Jun 2026 12:56:10 +0000 Subject: [PATCH 25/32] test: add checkpoint regression test for issue #228 Prove that schema extraction works when _delta_log contains only a checkpoint parquet file and no JSON commit logs (compacted-log scenario). The fixture writes a real Delta table via pyarrow, calls create_checkpoint(), then removes all .json logs. The test patches only FabAuth and the DeltaTable constructor (to redirect the abfss:// URI to the local fixture); the actual schema().to_json() call runs against a real delta-rs reader. --- tests/test_commands/test_tables_schema.py | 61 ++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/tests/test_commands/test_tables_schema.py b/tests/test_commands/test_tables_schema.py index ddcb86121..b993955ac 100644 --- a/tests/test_commands/test_tables_schema.py +++ b/tests/test_commands/test_tables_schema.py @@ -3,10 +3,14 @@ import json from argparse import Namespace -from unittest.mock import MagicMock, patch +from decimal import Decimal +from unittest.mock import patch +import pyarrow as pa import pytest +from deltalake import DeltaTable, write_deltalake from deltalake.exceptions import DeltaError, TableNotFoundError +from unittest.mock import MagicMock from fabric_cli.commands.tables import fab_tables_schema from fabric_cli.core import fab_constant @@ -396,3 +400,58 @@ def test_table_schema_success( calls = mock_questionary_print.call_args_list assert any("Schema extracted successfully" in str(c) for c in calls) + + +class TestTablesSchemaCheckpointRegression: + """Regression for #228: schema must be readable from a checkpointed Delta table + that has no pre-checkpoint JSON commit logs (compacted-log scenario). + + The old implementation walked _delta_log/*.json manually; after log compaction + those files are removed and only the .checkpoint.parquet + _last_checkpoint + remain. The new implementation delegates to DeltaTable.schema(), which uses + delta-rs's native reader that prefers checkpoints over JSON logs. + """ + + @pytest.fixture + def checkpointed_delta_table(self, tmp_path): + """Real local Delta table: one checkpoint, JSON log removed.""" + table_path = tmp_path / "test_table" + df = pa.table({ + "id": pa.array([1, 2], pa.int64()), + "price": pa.array([Decimal("9.99"), Decimal("19.99")], pa.decimal128(10, 2)), + "created_at": pa.array([1_000_000, 2_000_000], pa.timestamp("us")), + }) + write_deltalake(str(table_path), df) + + dt = DeltaTable(str(table_path)) + dt.create_checkpoint() + + for json_log in (table_path / "_delta_log").glob("*.json"): + json_log.unlink() + + log_files = list((table_path / "_delta_log").iterdir()) + assert not any(f.suffix == ".json" for f in log_files), ( + "fixture must leave no JSON logs — only checkpoint parquet" + ) + + return table_path + + def test_schema_readable_after_log_compaction(self, checkpointed_delta_table): + """Schema extraction must succeed when only a checkpoint file exists.""" + real_dt = DeltaTable(str(checkpointed_delta_table)) + + args = Namespace( + ws_id="ws-id", lakehouse_id="lh-id", table_local_path="Tables/test_table" + ) + + with patch(f"{_DELTA_CLIENT}.FabAuth") as mock_auth, \ + patch(f"{_DELTA_CLIENT}.DeltaTable", return_value=real_dt): + mock_auth.return_value.get_access_token.return_value = "mock_token" + fields = fab_tables_schema._get_table_schema(args) + + names = [f["name"] for f in fields] + assert names == ["id", "price", "created_at"] + + assert fields[0]["type"] == "long" + assert fields[1]["type"] == "decimal(10,2)" + assert fields[2]["type"] == "timestamp_ntz" From d0a7e233231b2eb8e3a06e0167c9916013ff78c2 Mon Sep 17 00:00:00 2001 From: Piotr Kontek Date: Thu, 18 Jun 2026 13:00:11 +0000 Subject: [PATCH 26/32] refactor: split test_tables_schema by taxonomy per test.instructions.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure unit tests → tests/test_core/test_delta_client.py Utils tests → tests/test_utils/test_fab_cmd_table_utils.py Integration → tests/test_commands/test_tables_schema.py (only) --- tests/test_commands/test_tables_schema.py | 413 +------------------ tests/test_core/test_delta_client.py | 300 ++++++++++++++ tests/test_utils/test_fab_cmd_table_utils.py | 41 ++ 3 files changed, 342 insertions(+), 412 deletions(-) create mode 100644 tests/test_core/test_delta_client.py create mode 100644 tests/test_utils/test_fab_cmd_table_utils.py diff --git a/tests/test_commands/test_tables_schema.py b/tests/test_commands/test_tables_schema.py index b993955ac..38dee8186 100644 --- a/tests/test_commands/test_tables_schema.py +++ b/tests/test_commands/test_tables_schema.py @@ -2,373 +2,17 @@ # Licensed under the MIT License. import json -from argparse import Namespace -from decimal import Decimal -from unittest.mock import patch +from unittest.mock import MagicMock, patch -import pyarrow as pa import pytest -from deltalake import DeltaTable, write_deltalake -from deltalake.exceptions import DeltaError, TableNotFoundError -from unittest.mock import MagicMock -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 fabric_cli.utils import fab_cmd_table_utils as utils_table from tests.conftest import mock_questionary_print # noqa: F401 from tests.test_commands.commands_parser import CLIExecutor _DELTA_CLIENT = "fabric_cli.client.fab_delta_client" -class TestTablesSchemaUnit: - """Unit tests for table schema command - direct function calls without VCR.""" - - @pytest.fixture - def mock_auth(self): - with patch(f"{_DELTA_CLIENT}.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(f"{_DELTA_CLIENT}.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_local_path="Tables/test_table", - ) - - 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_local_path="Tables/dbo/test_table", - ) - - 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_local_path="Tables/test_table", - ) - - 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_local_path="Tables/test_table", - ) - - 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_local_path="Tables/test_table", - ) - - 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_local_path="Tables/test_table", - ) - - 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_complex_schema_field_contract(self, mock_auth, mock_delta_table): - """Lock the exact JSON shape returned for complex Delta types. - - delta-rs serialises Arrow → Delta-protocol JSON via Schema.to_json(). - The mapping below was validated against the installed deltalake wheel - and must be stable for users who pipe --output_format json into scripts. - - Verified mappings: - pyarrow int64 → "long" (NOT "integer") - pyarrow decimal128 → "decimal(10,2)" (compact string, NOT an object) - pyarrow timestamp('us')→ "timestamp_ntz" (NOT "timestamp") - map / struct → nested objects with keyType/valueType/fields - """ - complex_schema_json = { - "type": "struct", - "fields": [ - {"name": "id", "type": "long", "nullable": False, "metadata": {}}, - {"name": "price", "type": "decimal(10,2)", "nullable": True, "metadata": {}}, - {"name": "created_at", "type": "timestamp_ntz", "nullable": True, "metadata": {}}, - { - "name": "tags", - "type": { - "type": "map", - "keyType": "string", - "valueType": "string", - "valueContainsNull": True, - }, - "nullable": True, - "metadata": {}, - }, - { - "name": "address", - "type": { - "type": "struct", - "fields": [ - {"name": "street", "type": "string", "nullable": True, "metadata": {}}, - {"name": "city", "type": "string", "nullable": True, "metadata": {}}, - ], - }, - "nullable": True, - "metadata": {}, - }, - ], - } - args = Namespace( - ws_id="ws", lakehouse_id="lh", table_local_path="Tables/complex_table" - ) - self._make_delta_table_mock(mock_delta_table, json.dumps(complex_schema_json)) - - fields = fab_tables_schema._get_table_schema(args) - - assert len(fields) == 5 - - assert fields[0] == {"name": "id", "type": "long", "nullable": False, "metadata": {}} - - assert fields[1] == {"name": "price", "type": "decimal(10,2)", "nullable": True, "metadata": {}} - - assert fields[2] == {"name": "created_at", "type": "timestamp_ntz", "nullable": True, "metadata": {}} - - assert fields[3] == { - "name": "tags", - "type": {"type": "map", "keyType": "string", "valueType": "string", "valueContainsNull": True}, - "nullable": True, - "metadata": {}, - } - - assert fields[4] == { - "name": "address", - "type": { - "type": "struct", - "fields": [ - {"name": "street", "type": "string", "nullable": True, "metadata": {}}, - {"name": "city", "type": "string", "nullable": True, "metadata": {}}, - ], - }, - "nullable": True, - "metadata": {}, - } - - 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_local_path="Tables/my_table", - ) - - 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 TestDeltaItemTypeValidation: - """Regression tests: only item types with Delta-compatible Tables/ are accepted.""" - - @pytest.fixture - def mock_auth(self): - with patch(f"{_DELTA_CLIENT}.FabAuth") as mock: - mock.return_value.get_access_token.return_value = "mock_token" - yield mock - - @pytest.fixture - def mock_delta_table(self): - with patch(f"{_DELTA_CLIENT}.DeltaTable") as mock: - mock_schema = MagicMock() - mock_schema.to_json.return_value = json.dumps({"fields": []}) - mock.return_value.schema.return_value = mock_schema - yield mock - - @pytest.mark.parametrize("item_type", [ - "Lakehouse", "Warehouse", "KQLDatabase", "MirroredDatabase", "SQLDatabase", - ]) - def test_supported_item_types_pass_validation(self, mock_auth, mock_delta_table, item_type): - args = Namespace( - ws_id="ws", lakehouse_id="lh", table_local_path="Tables/t", item_type=item_type - ) - # should not raise - fab_tables_schema._get_table_schema(args) - - def test_semantic_model_raises_clear_error(self, mock_auth, mock_delta_table): - args = Namespace( - ws_id="ws", lakehouse_id="lh", table_local_path="Tables/t", item_type="SemanticModel" - ) - with pytest.raises(FabricCLIError) as exc_info: - fab_tables_schema._get_table_schema(args) - assert exc_info.value.status_code == fab_constant.ERROR_INVALID_ITEM_TYPE - assert "SemanticModel" in exc_info.value.message - assert "Delta" in exc_info.value.message - - def test_missing_item_type_does_not_raise(self, mock_auth, mock_delta_table): - """item_type is absent when _get_table_schema is called directly in unit tests.""" - args = Namespace(ws_id="ws", lakehouse_id="lh", table_local_path="Tables/t") - fab_tables_schema._get_table_schema(args) - - -class TestAddTablePropsToArgs: - """Tests for add_table_props_to_args normalization.""" - - def _make_context(self, local_path: str) -> MagicMock: - from fabric_cli.core.hiearchy.fab_onelake_element import OneLakeItem - - context = MagicMock() - context.__class__ = OneLakeItem # make isinstance(context, OneLakeItem) pass - context.local_path = local_path - return context - - def test_shortcut_suffix_stripped_from_table_local_path(self): - """Regression: .Shortcut must not appear in args.table_local_path.""" - args = Namespace() - context = self._make_context("Tables/my_table.Shortcut") - - utils_table.add_table_props_to_args(args, context) - - assert ".Shortcut" not in args.table_local_path - assert args.table_local_path == "Tables/my_table" - - def test_shortcut_suffix_stripped_from_schema_path(self): - """Regression: .Shortcut must not appear anywhere in table_local_path for schema tables.""" - args = Namespace() - context = self._make_context("Tables/dbo/my_table.Shortcut") - - utils_table.add_table_props_to_args(args, context) - - assert ".Shortcut" not in args.table_local_path - assert args.table_local_path == "Tables/dbo/my_table" - - def test_normal_path_unchanged(self): - args = Namespace() - context = self._make_context("Tables/my_table") - - utils_table.add_table_props_to_args(args, context) - - assert args.table_local_path == "Tables/my_table" - - def test_schema_path_unchanged(self): - args = Namespace() - context = self._make_context("Tables/dbo/my_table") - - utils_table.add_table_props_to_args(args, context) - - assert args.table_local_path == "Tables/dbo/my_table" - - class TestTablesSchemaIntegration: """Integration tests for table schema command - validates full dispatch stack.""" @@ -400,58 +44,3 @@ def test_table_schema_success( calls = mock_questionary_print.call_args_list assert any("Schema extracted successfully" in str(c) for c in calls) - - -class TestTablesSchemaCheckpointRegression: - """Regression for #228: schema must be readable from a checkpointed Delta table - that has no pre-checkpoint JSON commit logs (compacted-log scenario). - - The old implementation walked _delta_log/*.json manually; after log compaction - those files are removed and only the .checkpoint.parquet + _last_checkpoint - remain. The new implementation delegates to DeltaTable.schema(), which uses - delta-rs's native reader that prefers checkpoints over JSON logs. - """ - - @pytest.fixture - def checkpointed_delta_table(self, tmp_path): - """Real local Delta table: one checkpoint, JSON log removed.""" - table_path = tmp_path / "test_table" - df = pa.table({ - "id": pa.array([1, 2], pa.int64()), - "price": pa.array([Decimal("9.99"), Decimal("19.99")], pa.decimal128(10, 2)), - "created_at": pa.array([1_000_000, 2_000_000], pa.timestamp("us")), - }) - write_deltalake(str(table_path), df) - - dt = DeltaTable(str(table_path)) - dt.create_checkpoint() - - for json_log in (table_path / "_delta_log").glob("*.json"): - json_log.unlink() - - log_files = list((table_path / "_delta_log").iterdir()) - assert not any(f.suffix == ".json" for f in log_files), ( - "fixture must leave no JSON logs — only checkpoint parquet" - ) - - return table_path - - def test_schema_readable_after_log_compaction(self, checkpointed_delta_table): - """Schema extraction must succeed when only a checkpoint file exists.""" - real_dt = DeltaTable(str(checkpointed_delta_table)) - - args = Namespace( - ws_id="ws-id", lakehouse_id="lh-id", table_local_path="Tables/test_table" - ) - - with patch(f"{_DELTA_CLIENT}.FabAuth") as mock_auth, \ - patch(f"{_DELTA_CLIENT}.DeltaTable", return_value=real_dt): - mock_auth.return_value.get_access_token.return_value = "mock_token" - fields = fab_tables_schema._get_table_schema(args) - - names = [f["name"] for f in fields] - assert names == ["id", "price", "created_at"] - - assert fields[0]["type"] == "long" - assert fields[1]["type"] == "decimal(10,2)" - assert fields[2]["type"] == "timestamp_ntz" diff --git a/tests/test_core/test_delta_client.py b/tests/test_core/test_delta_client.py new file mode 100644 index 000000000..bca1747c3 --- /dev/null +++ b/tests/test_core/test_delta_client.py @@ -0,0 +1,300 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import json +from argparse import Namespace +from decimal import Decimal +from unittest.mock import MagicMock, patch + +import pyarrow as pa +import pytest +from deltalake import DeltaTable, write_deltalake +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 + +_DELTA_CLIENT = "fabric_cli.client.fab_delta_client" + + +class TestDeltaClientSchemaUnit: + """Unit tests for fab_delta_client schema extraction — no network, no VCR.""" + + @pytest.fixture + def mock_auth(self): + with patch(f"{_DELTA_CLIENT}.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(f"{_DELTA_CLIENT}.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): + args = Namespace( + ws_id="test-ws-id", + lakehouse_id="test-lakehouse-id", + table_local_path="Tables/test_table", + ) + 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 len(result) == 2 + assert result[0] == {"name": "id", "type": "integer", "nullable": False, "metadata": {}} + assert result[1] == {"name": "name", "type": "string", "nullable": True, "metadata": {}} + + 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_schema_namespace(self, mock_auth, mock_delta_table): + """Schema-qualified path (e.g. Tables/dbo/table) passes the full path to DeltaTable.""" + args = Namespace( + ws_id="test-ws-id", + lakehouse_id="test-lakehouse-id", + table_local_path="Tables/dbo/test_table", + ) + self._make_delta_table_mock( + mock_delta_table, + json.dumps({"fields": [{"name": "col1", "type": "long", "nullable": True, "metadata": {}}]}), + ) + + result = fab_tables_schema._get_table_schema(args) + + assert mock_delta_table.call_args[0][0].endswith("Tables/dbo/test_table") + assert result[0]["name"] == "col1" + + def test_abfss_uri_format(self, mock_auth, mock_delta_table): + """DeltaTable must be called with a well-formed abfss:// URI.""" + args = Namespace( + ws_id="workspace-guid-123", + lakehouse_id="lakehouse-guid-456", + table_local_path="Tables/my_table", + ) + self._make_delta_table_mock( + mock_delta_table, + json.dumps({"fields": [{"name": "c", "type": "string", "nullable": True, "metadata": {}}]}), + ) + + fab_tables_schema._get_table_schema(args) + + uri = mock_delta_table.call_args[0][0] + assert uri.startswith("abfss://workspace-guid-123@") + assert "lakehouse-guid-456" in uri + assert "Tables/my_table" in uri + opts = mock_delta_table.call_args[1]["storage_options"] + assert opts["bearer_token"] == "mock_token" + assert opts["use_fabric_endpoint"] == "true" + + @pytest.mark.parametrize("error_cls", [TableNotFoundError, DeltaError]) + def test_delta_exceptions_map_to_fabric_cli_error(self, mock_auth, mock_delta_table, error_cls): + args = Namespace( + ws_id="ws", lakehouse_id="lh", table_local_path="Tables/t" + ) + 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_invalid_json_maps_to_fabric_cli_error(self, mock_auth, mock_delta_table): + args = Namespace(ws_id="ws", lakehouse_id="lh", table_local_path="Tables/t") + 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 + + def test_missing_fields_key_maps_to_fabric_cli_error(self, mock_auth, mock_delta_table): + args = Namespace(ws_id="ws", lakehouse_id="lh", table_local_path="Tables/t") + self._make_delta_table_mock(mock_delta_table, json.dumps({"other": "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 + + def test_fields_not_list_maps_to_fabric_cli_error(self, mock_auth, mock_delta_table): + args = Namespace(ws_id="ws", lakehouse_id="lh", table_local_path="Tables/t") + 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 + + def test_complex_schema_field_contract(self, mock_auth, mock_delta_table): + """Lock the exact JSON shape returned for complex Delta types. + + delta-rs serialises Arrow → Delta-protocol JSON via Schema.to_json(). + The mapping below was validated against the installed deltalake wheel + and must be stable for users who pipe --output_format json into scripts. + + Verified mappings: + pyarrow int64 → "long" (NOT "integer") + pyarrow decimal128 → "decimal(10,2)" (compact string, NOT an object) + pyarrow timestamp('us') → "timestamp_ntz" (NOT "timestamp") + map / struct → nested objects with keyType/valueType/fields + """ + complex_schema_json = { + "type": "struct", + "fields": [ + {"name": "id", "type": "long", "nullable": False, "metadata": {}}, + {"name": "price", "type": "decimal(10,2)", "nullable": True, "metadata": {}}, + {"name": "created_at", "type": "timestamp_ntz", "nullable": True, "metadata": {}}, + { + "name": "tags", + "type": {"type": "map", "keyType": "string", "valueType": "string", "valueContainsNull": True}, + "nullable": True, + "metadata": {}, + }, + { + "name": "address", + "type": { + "type": "struct", + "fields": [ + {"name": "street", "type": "string", "nullable": True, "metadata": {}}, + {"name": "city", "type": "string", "nullable": True, "metadata": {}}, + ], + }, + "nullable": True, + "metadata": {}, + }, + ], + } + args = Namespace(ws_id="ws", lakehouse_id="lh", table_local_path="Tables/complex_table") + self._make_delta_table_mock(mock_delta_table, json.dumps(complex_schema_json)) + + fields = fab_tables_schema._get_table_schema(args) + + assert len(fields) == 5 + assert fields[0] == {"name": "id", "type": "long", "nullable": False, "metadata": {}} + assert fields[1] == {"name": "price", "type": "decimal(10,2)", "nullable": True, "metadata": {}} + assert fields[2] == {"name": "created_at", "type": "timestamp_ntz", "nullable": True, "metadata": {}} + assert fields[3] == { + "name": "tags", + "type": {"type": "map", "keyType": "string", "valueType": "string", "valueContainsNull": True}, + "nullable": True, + "metadata": {}, + } + assert fields[4] == { + "name": "address", + "type": { + "type": "struct", + "fields": [ + {"name": "street", "type": "string", "nullable": True, "metadata": {}}, + {"name": "city", "type": "string", "nullable": True, "metadata": {}}, + ], + }, + "nullable": True, + "metadata": {}, + } + + +class TestDeltaItemTypeValidation: + """Only item types with a Delta-compatible Tables/ folder are accepted.""" + + @pytest.fixture + def mock_auth(self): + with patch(f"{_DELTA_CLIENT}.FabAuth") as mock: + mock.return_value.get_access_token.return_value = "mock_token" + yield mock + + @pytest.fixture + def mock_delta_table(self): + with patch(f"{_DELTA_CLIENT}.DeltaTable") as mock: + schema = MagicMock() + schema.to_json.return_value = json.dumps({"fields": []}) + mock.return_value.schema.return_value = schema + yield mock + + @pytest.mark.parametrize("item_type", [ + "Lakehouse", "Warehouse", "KQLDatabase", "MirroredDatabase", "SQLDatabase", + ]) + def test_supported_item_types_pass_validation(self, mock_auth, mock_delta_table, item_type): + args = Namespace(ws_id="ws", lakehouse_id="lh", table_local_path="Tables/t", item_type=item_type) + fab_tables_schema._get_table_schema(args) # must not raise + + def test_semantic_model_raises_clear_error(self, mock_auth, mock_delta_table): + args = Namespace(ws_id="ws", lakehouse_id="lh", table_local_path="Tables/t", item_type="SemanticModel") + with pytest.raises(FabricCLIError) as exc_info: + fab_tables_schema._get_table_schema(args) + assert exc_info.value.status_code == fab_constant.ERROR_INVALID_ITEM_TYPE + assert "SemanticModel" in exc_info.value.message + assert "Delta" in exc_info.value.message + + def test_missing_item_type_does_not_raise(self, mock_auth, mock_delta_table): + """item_type may be absent when called directly in unit tests.""" + args = Namespace(ws_id="ws", lakehouse_id="lh", table_local_path="Tables/t") + fab_tables_schema._get_table_schema(args) # must not raise + + +class TestTablesSchemaCheckpointRegression: + """Regression for #228: schema must be readable when only a checkpoint exists. + + The old implementation walked _delta_log/*.json manually; after log compaction + those files are removed and only *.checkpoint.parquet + _last_checkpoint remain. + The new implementation delegates to DeltaTable.schema(), which uses delta-rs's + native reader that prefers checkpoints over JSON logs. + """ + + @pytest.fixture + def checkpointed_delta_table(self, tmp_path): + """Real local Delta table: checkpoint written, JSON commit log removed.""" + table_path = tmp_path / "test_table" + df = pa.table({ + "id": pa.array([1, 2], pa.int64()), + "price": pa.array([Decimal("9.99"), Decimal("19.99")], pa.decimal128(10, 2)), + "created_at": pa.array([1_000_000, 2_000_000], pa.timestamp("us")), + }) + write_deltalake(str(table_path), df) + + dt = DeltaTable(str(table_path)) + dt.create_checkpoint() + + for json_log in (table_path / "_delta_log").glob("*.json"): + json_log.unlink() + + log_files = list((table_path / "_delta_log").iterdir()) + assert not any(f.suffix == ".json" for f in log_files), ( + "fixture must leave no JSON logs — only checkpoint parquet" + ) + return table_path + + def test_schema_readable_after_log_compaction(self, checkpointed_delta_table): + """Schema extraction succeeds when only a checkpoint parquet file exists.""" + real_dt = DeltaTable(str(checkpointed_delta_table)) + args = Namespace(ws_id="ws-id", lakehouse_id="lh-id", table_local_path="Tables/test_table") + + with patch(f"{_DELTA_CLIENT}.FabAuth") as mock_auth, \ + patch(f"{_DELTA_CLIENT}.DeltaTable", return_value=real_dt): + mock_auth.return_value.get_access_token.return_value = "mock_token" + fields = fab_tables_schema._get_table_schema(args) + + assert [f["name"] for f in fields] == ["id", "price", "created_at"] + assert fields[0]["type"] == "long" + assert fields[1]["type"] == "decimal(10,2)" + assert fields[2]["type"] == "timestamp_ntz" diff --git a/tests/test_utils/test_fab_cmd_table_utils.py b/tests/test_utils/test_fab_cmd_table_utils.py new file mode 100644 index 000000000..d186ab103 --- /dev/null +++ b/tests/test_utils/test_fab_cmd_table_utils.py @@ -0,0 +1,41 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from argparse import Namespace +from unittest.mock import MagicMock + +from fabric_cli.core.hiearchy.fab_onelake_element import OneLakeItem +from fabric_cli.utils import fab_cmd_table_utils as utils_table + + +def _make_context(local_path: str) -> MagicMock: + context = MagicMock() + context.__class__ = OneLakeItem # make isinstance(context, OneLakeItem) pass + context.local_path = local_path + return context + + +class TestAddTablePropsToArgs: + """Unit tests for add_table_props_to_args path normalization.""" + + def test_shortcut_suffix_stripped_from_table_local_path(self): + """Regression: .Shortcut must not appear in args.table_local_path.""" + args = Namespace() + utils_table.add_table_props_to_args(args, _make_context("Tables/my_table.Shortcut")) + assert args.table_local_path == "Tables/my_table" + + def test_shortcut_suffix_stripped_from_schema_qualified_path(self): + """Regression: .Shortcut must not appear in schema-qualified table_local_path.""" + args = Namespace() + utils_table.add_table_props_to_args(args, _make_context("Tables/dbo/my_table.Shortcut")) + assert args.table_local_path == "Tables/dbo/my_table" + + def test_normal_path_unchanged(self): + args = Namespace() + utils_table.add_table_props_to_args(args, _make_context("Tables/my_table")) + assert args.table_local_path == "Tables/my_table" + + def test_schema_qualified_path_unchanged(self): + args = Namespace() + utils_table.add_table_props_to_args(args, _make_context("Tables/dbo/my_table")) + assert args.table_local_path == "Tables/dbo/my_table" From 82c732bc38d716075d58607bcf36762f94cc9aa7 Mon Sep 17 00:00:00 2001 From: Piotr Kontek Date: Thu, 18 Jun 2026 13:12:09 +0000 Subject: [PATCH 27/32] refactor: replace VCR cassette with pure dispatch test for table schema The cassette only exercised workspace/lakehouse CRUD that already has coverage elsewhere; DeltaTable and FabAuth were both mocked out. --- .../test_table_schema_success.yaml | 258 ------------------ tests/test_commands/test_tables_schema.py | 54 ++-- 2 files changed, 18 insertions(+), 294 deletions(-) delete mode 100644 tests/test_commands/recordings/test_commands/test_tables_schema/test_table_schema_success.yaml 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 deleted file mode 100644 index 67735e36a..000000000 --- a/tests/test_commands/recordings/test_commands/test_tables_schema/test_table_schema_success.yaml +++ /dev/null @@ -1,258 +0,0 @@ -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 index 38dee8186..262eccfd7 100644 --- a/tests/test_commands/test_tables_schema.py +++ b/tests/test_commands/test_tables_schema.py @@ -1,46 +1,28 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -import json from unittest.mock import MagicMock, patch -import pytest - -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 -_DELTA_CLIENT = "fabric_cli.client.fab_delta_client" +_SCHEMA_COMMAND = "fabric_cli.commands.tables.fab_tables.schema_command" 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( - f"{_DELTA_CLIENT}.DeltaTable" - ) as mock_dt, patch( - f"{_DELTA_CLIENT}.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) + """Dispatch test: verifies the parser routes 'table schema ' to schema_command.""" + + def test_table_schema_dispatches_to_schema_command(self, cli_executor: CLIExecutor): + with patch(_SCHEMA_COMMAND) as mock_cmd: + cli_executor.exec_command("table schema /ws.Workspace/lh.Lakehouse/Tables/my_table") + + mock_cmd.assert_called_once() + args = mock_cmd.call_args[0][0] + assert args.path == ["/ws.Workspace/lh.Lakehouse/Tables/my_table"] + + def test_table_schema_dispatches_with_schema_namespace(self, cli_executor: CLIExecutor): + with patch(_SCHEMA_COMMAND) as mock_cmd: + cli_executor.exec_command("table schema /ws.Workspace/lh.Lakehouse/Tables/dbo/my_table") + + mock_cmd.assert_called_once() + args = mock_cmd.call_args[0][0] + assert args.path == ["/ws.Workspace/lh.Lakehouse/Tables/dbo/my_table"] From b7d083142c5ed62e6f95c43a438e06b34ee984c9 Mon Sep 17 00:00:00 2001 From: Piotr Kontek Date: Thu, 18 Jun 2026 13:21:45 +0000 Subject: [PATCH 28/32] test: add missing auth token None test for fab_delta_client --- tests/test_core/test_delta_client.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_core/test_delta_client.py b/tests/test_core/test_delta_client.py index bca1747c3..743a5e30c 100644 --- a/tests/test_core/test_delta_client.py +++ b/tests/test_core/test_delta_client.py @@ -41,6 +41,14 @@ def _make_delta_table_mock(self, mock_delta_table, schema_json): mock_table_instance.schema.return_value = mock_arrow_schema mock_delta_table.return_value = mock_table_instance + def test_auth_token_none_raises_authentication_error(self): + args = Namespace(ws_id="ws", lakehouse_id="lh", table_local_path="Tables/t") + with patch(f"{_DELTA_CLIENT}.FabAuth") as mock_auth: + mock_auth.return_value.get_access_token.return_value = None + with pytest.raises(FabricCLIError) as exc_info: + fab_tables_schema._get_table_schema(args) + assert exc_info.value.status_code == fab_constant.ERROR_AUTHENTICATION_FAILED + def test_get_table_schema_success(self, mock_auth, mock_delta_table): args = Namespace( ws_id="test-ws-id", From 3761717473d461f3b8d8a191a42d788324ab27ca Mon Sep 17 00:00:00 2001 From: Piotr Kontek Date: Thu, 18 Jun 2026 13:26:48 +0000 Subject: [PATCH 29/32] hore: tighten deltalake dependency to >=1.0.0,<2.0.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9995a06c9..a78ce9d6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ dependencies = [ "requests", "cryptography", "fabric-cicd>=0.3.1", - "deltalake>=0.18.0", + "deltalake>=1.0.0,<2.0.0", ] [project.scripts] From df0360bccb8ed780cdb1cf4bd162c0d4a26ed576 Mon Sep 17 00:00:00 2001 From: Piotr Kontek Date: Thu, 18 Jun 2026 13:36:43 +0000 Subject: [PATCH 30/32] fix: strip .Shortcut from args.table_name in add_table_props_to_args --- src/fabric_cli/utils/fab_cmd_table_utils.py | 2 +- tests/test_utils/test_fab_cmd_table_utils.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/fabric_cli/utils/fab_cmd_table_utils.py b/src/fabric_cli/utils/fab_cmd_table_utils.py index 610457e04..d7fded41d 100644 --- a/src/fabric_cli/utils/fab_cmd_table_utils.py +++ b/src/fabric_cli/utils/fab_cmd_table_utils.py @@ -22,7 +22,7 @@ def add_table_props_to_args(args: Any, context: OneLakeItem) -> None: args.lakehouse_path = context.item.path table_path = context.local_path.split("/") - args.table_name = table_path[-1] + args.table_name = utils.remove_dot_suffix(table_path[-1]) args.schema = table_path[-2] if len(table_path) == 3 else None args.table_local_path = utils.remove_dot_suffix(context.local_path) diff --git a/tests/test_utils/test_fab_cmd_table_utils.py b/tests/test_utils/test_fab_cmd_table_utils.py index d186ab103..bcff4f634 100644 --- a/tests/test_utils/test_fab_cmd_table_utils.py +++ b/tests/test_utils/test_fab_cmd_table_utils.py @@ -18,6 +18,12 @@ def _make_context(local_path: str) -> MagicMock: class TestAddTablePropsToArgs: """Unit tests for add_table_props_to_args path normalization.""" + def test_shortcut_suffix_stripped_from_table_name(self): + """Regression: .Shortcut must not appear in args.table_name (used in REST URIs).""" + args = Namespace() + utils_table.add_table_props_to_args(args, _make_context("Tables/my_table.Shortcut")) + assert args.table_name == "my_table" + def test_shortcut_suffix_stripped_from_table_local_path(self): """Regression: .Shortcut must not appear in args.table_local_path.""" args = Namespace() From eebb6f27bd44ea80ccfdec090a7704dc54d6824b Mon Sep 17 00:00:00 2001 From: Piotr Kontek Date: Thu, 18 Jun 2026 13:38:24 +0000 Subject: [PATCH 31/32] style: apply black formatting to changed files --- src/fabric_cli/client/fab_delta_client.py | 20 +- tests/test_commands/test_tables_schema.py | 12 +- tests/test_core/test_delta_client.py | 204 +++++++++++++++---- tests/test_utils/test_fab_cmd_table_utils.py | 12 +- 4 files changed, 193 insertions(+), 55 deletions(-) diff --git a/src/fabric_cli/client/fab_delta_client.py b/src/fabric_cli/client/fab_delta_client.py index 40445e60e..b3fa17c97 100644 --- a/src/fabric_cli/client/fab_delta_client.py +++ b/src/fabric_cli/client/fab_delta_client.py @@ -15,13 +15,15 @@ # Item types whose OneLake Tables/ folder contains standard Delta tables. # SemanticModel is explicitly excluded: its Tables/ entries are columnar # semantic-model representations, not Delta-compatible parquet. -_DELTA_SUPPORTED_ITEM_TYPES: frozenset[str] = frozenset({ - "Lakehouse", - "Warehouse", - "KQLDatabase", - "MirroredDatabase", - "SQLDatabase", -}) +_DELTA_SUPPORTED_ITEM_TYPES: frozenset[str] = frozenset( + { + "Lakehouse", + "Warehouse", + "KQLDatabase", + "MirroredDatabase", + "SQLDatabase", + } +) def get_table_schema(args: Namespace, local_path: str) -> list[dict]: @@ -61,7 +63,9 @@ def get_table_schema(args: Namespace, local_path: str) -> list[dict]: schema_dict = json.loads(table.schema().to_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.") + 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( diff --git a/tests/test_commands/test_tables_schema.py b/tests/test_commands/test_tables_schema.py index 262eccfd7..f514451e1 100644 --- a/tests/test_commands/test_tables_schema.py +++ b/tests/test_commands/test_tables_schema.py @@ -13,15 +13,21 @@ class TestTablesSchemaIntegration: def test_table_schema_dispatches_to_schema_command(self, cli_executor: CLIExecutor): with patch(_SCHEMA_COMMAND) as mock_cmd: - cli_executor.exec_command("table schema /ws.Workspace/lh.Lakehouse/Tables/my_table") + cli_executor.exec_command( + "table schema /ws.Workspace/lh.Lakehouse/Tables/my_table" + ) mock_cmd.assert_called_once() args = mock_cmd.call_args[0][0] assert args.path == ["/ws.Workspace/lh.Lakehouse/Tables/my_table"] - def test_table_schema_dispatches_with_schema_namespace(self, cli_executor: CLIExecutor): + def test_table_schema_dispatches_with_schema_namespace( + self, cli_executor: CLIExecutor + ): with patch(_SCHEMA_COMMAND) as mock_cmd: - cli_executor.exec_command("table schema /ws.Workspace/lh.Lakehouse/Tables/dbo/my_table") + cli_executor.exec_command( + "table schema /ws.Workspace/lh.Lakehouse/Tables/dbo/my_table" + ) mock_cmd.assert_called_once() args = mock_cmd.call_args[0][0] diff --git a/tests/test_core/test_delta_client.py b/tests/test_core/test_delta_client.py index 743a5e30c..bc50aaba3 100644 --- a/tests/test_core/test_delta_client.py +++ b/tests/test_core/test_delta_client.py @@ -66,8 +66,18 @@ def test_get_table_schema_success(self, mock_auth, mock_delta_table): result = fab_tables_schema._get_table_schema(args) assert len(result) == 2 - assert result[0] == {"name": "id", "type": "integer", "nullable": False, "metadata": {}} - assert result[1] == {"name": "name", "type": "string", "nullable": True, "metadata": {}} + assert result[0] == { + "name": "id", + "type": "integer", + "nullable": False, + "metadata": {}, + } + assert result[1] == { + "name": "name", + "type": "string", + "nullable": True, + "metadata": {}, + } call_args = mock_delta_table.call_args assert "test-lakehouse-id" in call_args[0][0] @@ -84,7 +94,18 @@ def test_get_table_schema_with_schema_namespace(self, mock_auth, mock_delta_tabl ) self._make_delta_table_mock( mock_delta_table, - json.dumps({"fields": [{"name": "col1", "type": "long", "nullable": True, "metadata": {}}]}), + json.dumps( + { + "fields": [ + { + "name": "col1", + "type": "long", + "nullable": True, + "metadata": {}, + } + ] + } + ), ) result = fab_tables_schema._get_table_schema(args) @@ -101,7 +122,18 @@ def test_abfss_uri_format(self, mock_auth, mock_delta_table): ) self._make_delta_table_mock( mock_delta_table, - json.dumps({"fields": [{"name": "c", "type": "string", "nullable": True, "metadata": {}}]}), + json.dumps( + { + "fields": [ + { + "name": "c", + "type": "string", + "nullable": True, + "metadata": {}, + } + ] + } + ), ) fab_tables_schema._get_table_schema(args) @@ -115,10 +147,10 @@ def test_abfss_uri_format(self, mock_auth, mock_delta_table): assert opts["use_fabric_endpoint"] == "true" @pytest.mark.parametrize("error_cls", [TableNotFoundError, DeltaError]) - def test_delta_exceptions_map_to_fabric_cli_error(self, mock_auth, mock_delta_table, error_cls): - args = Namespace( - ws_id="ws", lakehouse_id="lh", table_local_path="Tables/t" - ) + def test_delta_exceptions_map_to_fabric_cli_error( + self, mock_auth, mock_delta_table, error_cls + ): + args = Namespace(ws_id="ws", lakehouse_id="lh", table_local_path="Tables/t") mock_delta_table.side_effect = error_cls("error") with pytest.raises(FabricCLIError) as exc_info: @@ -136,7 +168,9 @@ def test_invalid_json_maps_to_fabric_cli_error(self, mock_auth, mock_delta_table assert exc_info.value.status_code == fab_constant.ERROR_INVALID_DELTA_TABLE - def test_missing_fields_key_maps_to_fabric_cli_error(self, mock_auth, mock_delta_table): + def test_missing_fields_key_maps_to_fabric_cli_error( + self, mock_auth, mock_delta_table + ): args = Namespace(ws_id="ws", lakehouse_id="lh", table_local_path="Tables/t") self._make_delta_table_mock(mock_delta_table, json.dumps({"other": "value"})) @@ -145,9 +179,13 @@ def test_missing_fields_key_maps_to_fabric_cli_error(self, mock_auth, mock_delta assert exc_info.value.status_code == fab_constant.ERROR_INVALID_DELTA_TABLE - def test_fields_not_list_maps_to_fabric_cli_error(self, mock_auth, mock_delta_table): + def test_fields_not_list_maps_to_fabric_cli_error( + self, mock_auth, mock_delta_table + ): args = Namespace(ws_id="ws", lakehouse_id="lh", table_local_path="Tables/t") - self._make_delta_table_mock(mock_delta_table, json.dumps({"fields": "not a list"})) + 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) @@ -170,12 +208,27 @@ def test_complex_schema_field_contract(self, mock_auth, mock_delta_table): complex_schema_json = { "type": "struct", "fields": [ - {"name": "id", "type": "long", "nullable": False, "metadata": {}}, - {"name": "price", "type": "decimal(10,2)", "nullable": True, "metadata": {}}, - {"name": "created_at", "type": "timestamp_ntz", "nullable": True, "metadata": {}}, + {"name": "id", "type": "long", "nullable": False, "metadata": {}}, + { + "name": "price", + "type": "decimal(10,2)", + "nullable": True, + "metadata": {}, + }, + { + "name": "created_at", + "type": "timestamp_ntz", + "nullable": True, + "metadata": {}, + }, { "name": "tags", - "type": {"type": "map", "keyType": "string", "valueType": "string", "valueContainsNull": True}, + "type": { + "type": "map", + "keyType": "string", + "valueType": "string", + "valueContainsNull": True, + }, "nullable": True, "metadata": {}, }, @@ -184,8 +237,18 @@ def test_complex_schema_field_contract(self, mock_auth, mock_delta_table): "type": { "type": "struct", "fields": [ - {"name": "street", "type": "string", "nullable": True, "metadata": {}}, - {"name": "city", "type": "string", "nullable": True, "metadata": {}}, + { + "name": "street", + "type": "string", + "nullable": True, + "metadata": {}, + }, + { + "name": "city", + "type": "string", + "nullable": True, + "metadata": {}, + }, ], }, "nullable": True, @@ -193,18 +256,40 @@ def test_complex_schema_field_contract(self, mock_auth, mock_delta_table): }, ], } - args = Namespace(ws_id="ws", lakehouse_id="lh", table_local_path="Tables/complex_table") + args = Namespace( + ws_id="ws", lakehouse_id="lh", table_local_path="Tables/complex_table" + ) self._make_delta_table_mock(mock_delta_table, json.dumps(complex_schema_json)) fields = fab_tables_schema._get_table_schema(args) assert len(fields) == 5 - assert fields[0] == {"name": "id", "type": "long", "nullable": False, "metadata": {}} - assert fields[1] == {"name": "price", "type": "decimal(10,2)", "nullable": True, "metadata": {}} - assert fields[2] == {"name": "created_at", "type": "timestamp_ntz", "nullable": True, "metadata": {}} + assert fields[0] == { + "name": "id", + "type": "long", + "nullable": False, + "metadata": {}, + } + assert fields[1] == { + "name": "price", + "type": "decimal(10,2)", + "nullable": True, + "metadata": {}, + } + assert fields[2] == { + "name": "created_at", + "type": "timestamp_ntz", + "nullable": True, + "metadata": {}, + } assert fields[3] == { "name": "tags", - "type": {"type": "map", "keyType": "string", "valueType": "string", "valueContainsNull": True}, + "type": { + "type": "map", + "keyType": "string", + "valueType": "string", + "valueContainsNull": True, + }, "nullable": True, "metadata": {}, } @@ -213,8 +298,18 @@ def test_complex_schema_field_contract(self, mock_auth, mock_delta_table): "type": { "type": "struct", "fields": [ - {"name": "street", "type": "string", "nullable": True, "metadata": {}}, - {"name": "city", "type": "string", "nullable": True, "metadata": {}}, + { + "name": "street", + "type": "string", + "nullable": True, + "metadata": {}, + }, + { + "name": "city", + "type": "string", + "nullable": True, + "metadata": {}, + }, ], }, "nullable": True, @@ -239,15 +334,34 @@ def mock_delta_table(self): mock.return_value.schema.return_value = schema yield mock - @pytest.mark.parametrize("item_type", [ - "Lakehouse", "Warehouse", "KQLDatabase", "MirroredDatabase", "SQLDatabase", - ]) - def test_supported_item_types_pass_validation(self, mock_auth, mock_delta_table, item_type): - args = Namespace(ws_id="ws", lakehouse_id="lh", table_local_path="Tables/t", item_type=item_type) + @pytest.mark.parametrize( + "item_type", + [ + "Lakehouse", + "Warehouse", + "KQLDatabase", + "MirroredDatabase", + "SQLDatabase", + ], + ) + def test_supported_item_types_pass_validation( + self, mock_auth, mock_delta_table, item_type + ): + args = Namespace( + ws_id="ws", + lakehouse_id="lh", + table_local_path="Tables/t", + item_type=item_type, + ) fab_tables_schema._get_table_schema(args) # must not raise def test_semantic_model_raises_clear_error(self, mock_auth, mock_delta_table): - args = Namespace(ws_id="ws", lakehouse_id="lh", table_local_path="Tables/t", item_type="SemanticModel") + args = Namespace( + ws_id="ws", + lakehouse_id="lh", + table_local_path="Tables/t", + item_type="SemanticModel", + ) with pytest.raises(FabricCLIError) as exc_info: fab_tables_schema._get_table_schema(args) assert exc_info.value.status_code == fab_constant.ERROR_INVALID_ITEM_TYPE @@ -273,11 +387,15 @@ class TestTablesSchemaCheckpointRegression: def checkpointed_delta_table(self, tmp_path): """Real local Delta table: checkpoint written, JSON commit log removed.""" table_path = tmp_path / "test_table" - df = pa.table({ - "id": pa.array([1, 2], pa.int64()), - "price": pa.array([Decimal("9.99"), Decimal("19.99")], pa.decimal128(10, 2)), - "created_at": pa.array([1_000_000, 2_000_000], pa.timestamp("us")), - }) + df = pa.table( + { + "id": pa.array([1, 2], pa.int64()), + "price": pa.array( + [Decimal("9.99"), Decimal("19.99")], pa.decimal128(10, 2) + ), + "created_at": pa.array([1_000_000, 2_000_000], pa.timestamp("us")), + } + ) write_deltalake(str(table_path), df) dt = DeltaTable(str(table_path)) @@ -287,18 +405,22 @@ def checkpointed_delta_table(self, tmp_path): json_log.unlink() log_files = list((table_path / "_delta_log").iterdir()) - assert not any(f.suffix == ".json" for f in log_files), ( - "fixture must leave no JSON logs — only checkpoint parquet" - ) + assert not any( + f.suffix == ".json" for f in log_files + ), "fixture must leave no JSON logs — only checkpoint parquet" return table_path def test_schema_readable_after_log_compaction(self, checkpointed_delta_table): """Schema extraction succeeds when only a checkpoint parquet file exists.""" real_dt = DeltaTable(str(checkpointed_delta_table)) - args = Namespace(ws_id="ws-id", lakehouse_id="lh-id", table_local_path="Tables/test_table") + args = Namespace( + ws_id="ws-id", lakehouse_id="lh-id", table_local_path="Tables/test_table" + ) - with patch(f"{_DELTA_CLIENT}.FabAuth") as mock_auth, \ - patch(f"{_DELTA_CLIENT}.DeltaTable", return_value=real_dt): + with ( + patch(f"{_DELTA_CLIENT}.FabAuth") as mock_auth, + patch(f"{_DELTA_CLIENT}.DeltaTable", return_value=real_dt), + ): mock_auth.return_value.get_access_token.return_value = "mock_token" fields = fab_tables_schema._get_table_schema(args) diff --git a/tests/test_utils/test_fab_cmd_table_utils.py b/tests/test_utils/test_fab_cmd_table_utils.py index bcff4f634..4b7bf3bff 100644 --- a/tests/test_utils/test_fab_cmd_table_utils.py +++ b/tests/test_utils/test_fab_cmd_table_utils.py @@ -21,19 +21,25 @@ class TestAddTablePropsToArgs: def test_shortcut_suffix_stripped_from_table_name(self): """Regression: .Shortcut must not appear in args.table_name (used in REST URIs).""" args = Namespace() - utils_table.add_table_props_to_args(args, _make_context("Tables/my_table.Shortcut")) + utils_table.add_table_props_to_args( + args, _make_context("Tables/my_table.Shortcut") + ) assert args.table_name == "my_table" def test_shortcut_suffix_stripped_from_table_local_path(self): """Regression: .Shortcut must not appear in args.table_local_path.""" args = Namespace() - utils_table.add_table_props_to_args(args, _make_context("Tables/my_table.Shortcut")) + utils_table.add_table_props_to_args( + args, _make_context("Tables/my_table.Shortcut") + ) assert args.table_local_path == "Tables/my_table" def test_shortcut_suffix_stripped_from_schema_qualified_path(self): """Regression: .Shortcut must not appear in schema-qualified table_local_path.""" args = Namespace() - utils_table.add_table_props_to_args(args, _make_context("Tables/dbo/my_table.Shortcut")) + utils_table.add_table_props_to_args( + args, _make_context("Tables/dbo/my_table.Shortcut") + ) assert args.table_local_path == "Tables/dbo/my_table" def test_normal_path_unchanged(self): From 12bfa45541ad54e53aceddc1247348c4afc3537d Mon Sep 17 00:00:00 2001 From: pkontek Date: Thu, 18 Jun 2026 22:13:00 +0200 Subject: [PATCH 32/32] Fix deltalake version in requirements Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index ca684beb1..ef44d1c0b 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -12,7 +12,7 @@ psutil==7.0.0 requests cryptography fabric-cicd>=0.3.1 -deltalake>=0.18.0 +deltalake>=1.0.0,<2.0.0 # Testing and Building Requirements tox>=4.20.0