diff --git a/pyiceberg/cli/console.py b/pyiceberg/cli/console.py index 3feed9fb21..dc6ffee0f2 100644 --- a/pyiceberg/cli/console.py +++ b/pyiceberg/cli/console.py @@ -29,7 +29,7 @@ from pyiceberg import __version__ from pyiceberg.catalog import URI, Catalog, load_catalog from pyiceberg.cli.output import ConsoleOutput, JsonOutput, Output -from pyiceberg.exceptions import NoSuchNamespaceError, NoSuchPropertyException, NoSuchTableError +from pyiceberg.exceptions import NoSuchNamespaceError, NoSuchPropertyException, NoSuchTableError, NoSuchViewError from pyiceberg.io import WAREHOUSE from pyiceberg.table import TableProperties from pyiceberg.table.refs import SnapshotRef, SnapshotRefType @@ -142,12 +142,12 @@ def list(ctx: Context, parent: str | None) -> None: # pylint: disable=redefined @run.command() -@click.option("--entity", type=click.Choice(["any", "namespace", "table"]), default="any") +@click.option("--entity", type=click.Choice(["any", "namespace", "table", "view"]), default="any") @click.argument("identifier") @click.pass_context @catch_exception() -def describe(ctx: Context, entity: Literal["name", "namespace", "table"], identifier: str) -> None: - """Describe a namespace or a table.""" +def describe(ctx: Context, entity: Literal["any", "namespace", "table", "view"], identifier: str) -> None: + """Describe a namespace, a table, or a view.""" catalog, output = _catalog_and_output(ctx) identifier_tuple = Catalog.identifier_to_tuple(identifier) @@ -158,7 +158,7 @@ def describe(ctx: Context, entity: Literal["name", "namespace", "table"], identi output.describe_properties(namespace_properties) is_namespace = True except NoSuchNamespaceError as exc: - if entity != "any" or len(identifier_tuple) == 1: # type: ignore + if entity != "any" or len(identifier_tuple) == 1: raise exc is_table = False @@ -171,8 +171,18 @@ def describe(ctx: Context, entity: Literal["name", "namespace", "table"], identi if entity != "any": raise exc - if is_namespace is False and is_table is False: - raise NoSuchTableError(f"Table or namespace does not exist: {identifier}") + is_view = False + if entity in {"view", "any"} and len(identifier_tuple) > 1: + try: + catalog_view = catalog.load_view(identifier) + output.describe_view(catalog_view) + is_view = True + except (NoSuchViewError, NotImplementedError) as exc: + if entity != "any": + raise exc + + if is_namespace is False and is_table is False and is_view is False: + raise NoSuchTableError(f"Table, view, or namespace does not exist: {identifier}") @run.command() @@ -291,6 +301,18 @@ def namespace(ctx: Context, identifier: str) -> None: # noqa: F811 output.text(f"Dropped namespace: {identifier}") +@drop.command() +@click.argument("identifier") +@click.pass_context +@catch_exception() +def view(ctx: Context, identifier: str) -> None: # noqa: F811 + """Drop a view.""" + catalog, output = _catalog_and_output(ctx) + + catalog.drop_view(identifier) + output.text(f"Dropped view: {identifier}") + + @run.command() @click.argument("from_identifier") @click.argument("to_identifier") @@ -431,6 +453,17 @@ def table(ctx: Context, identifier: str, property_name: str) -> None: # noqa: F raise NoSuchPropertyException(f"Property {property_name} does not exist on {identifier}") +@run.command() +@click.argument("namespace") +@click.pass_context +@catch_exception() +def list_views(ctx: Context, namespace: str) -> None: + """List all views in a namespace.""" + catalog, output = _catalog_and_output(ctx) + identifiers = catalog.list_views(namespace) + output.identifiers(identifiers) + + @run.command() @click.argument("identifier") @click.option("--type", required=False) diff --git a/pyiceberg/cli/output.py b/pyiceberg/cli/output.py index 332221008c..7cbfa0163c 100644 --- a/pyiceberg/cli/output.py +++ b/pyiceberg/cli/output.py @@ -31,6 +31,7 @@ from pyiceberg.table.metadata import TableMetadata from pyiceberg.table.refs import SnapshotRefType from pyiceberg.typedef import IcebergBaseModel, Identifier, Properties +from pyiceberg.view import View class Output(ABC): @@ -45,6 +46,9 @@ def identifiers(self, identifiers: list[Identifier]) -> None: ... @abstractmethod def describe_table(self, table: Table) -> None: ... + @abstractmethod + def describe_view(self, view: View) -> None: ... + @abstractmethod def files(self, table: Table, history: bool) -> None: ... @@ -123,6 +127,31 @@ def describe_table(self, table: Table) -> None: output_table.add_row("Properties", table_properties) Console().print(output_table) + def describe_view(self, view: View) -> None: + metadata = view.metadata + view_properties = self._table + for key, value in metadata.properties.items(): + view_properties.add_row(key, value) + + schema_tree = Tree(f"Schema, id={view.current_version().schema_id}") + for field in view.schema().fields: + schema_tree.add(str(field)) + + current_version = view.current_version() + representations_tree = Tree("SQL representations") + for repr in current_version.representations: + representations_tree.add(f"[{repr.root.dialect}] {repr.root.sql}") + + output_table = self._table + output_table.add_row("View format version", str(metadata.format_version)) + output_table.add_row("View UUID", str(metadata.view_uuid)) + output_table.add_row("Location", metadata.location) + output_table.add_row("Current version", str(metadata.current_version_id)) + output_table.add_row("Current schema", schema_tree) + output_table.add_row("SQL", representations_tree) + output_table.add_row("Properties", view_properties) + Console().print(output_table) + def files(self, table: Table, history: bool) -> None: if history: snapshots = table.metadata.snapshots @@ -216,6 +245,9 @@ class FauxTable(IcebergBaseModel): ).model_dump_json() ) + def describe_view(self, view: View) -> None: + print(view.metadata.model_dump_json()) + def describe_properties(self, properties: Properties) -> None: self._out(properties) diff --git a/tests/cli/test_console.py b/tests/cli/test_console.py index 27a1bfebe4..46f0968e5a 100644 --- a/tests/cli/test_console.py +++ b/tests/cli/test_console.py @@ -18,6 +18,7 @@ import os import uuid from pathlib import PosixPath +from typing import Any from unittest import mock from unittest.mock import MagicMock @@ -28,6 +29,7 @@ from pyiceberg import __version__ from pyiceberg.catalog.memory import InMemoryCatalog from pyiceberg.cli.console import run +from pyiceberg.exceptions import NoSuchTableError, NoSuchViewError from pyiceberg.io import WAREHOUSE from pyiceberg.partitioning import PartitionField, PartitionSpec from pyiceberg.schema import Schema @@ -35,6 +37,8 @@ from pyiceberg.typedef import Properties from pyiceberg.types import LongType, NestedField from pyiceberg.utils.config import Config +from pyiceberg.view import View +from pyiceberg.view.metadata import ViewMetadata def test_missing_uri(mocker: MockFixture, empty_home_dir_path: str) -> None: @@ -219,7 +223,7 @@ def test_describe_table_does_not_exists(catalog: InMemoryCatalog) -> None: runner = CliRunner() result = runner.invoke(run, ["describe", "default.doesnotexist"]) assert result.exit_code == 1 - assert result.output == "Table or namespace does not exist: default.doesnotexist\n" + assert result.output == "Table, view, or namespace does not exist: default.doesnotexist\n" def test_schema(catalog: InMemoryCatalog) -> None: @@ -655,7 +659,7 @@ def test_json_describe_table_does_not_exists(catalog: InMemoryCatalog) -> None: assert result.exit_code == 1 assert ( result.output - == """{"type": "NoSuchTableError", "message": "Table or namespace does not exist: default.doesnotexist"}\n""" + == """{"type": "NoSuchTableError", "message": "Table, view, or namespace does not exist: default.doesnotexist"}\n""" ) @@ -1089,3 +1093,136 @@ def test_warehouse_cli_option_forwarded_to_catalog(mocker: MockFixture) -> None: assert result.exit_code == 0 mock_basicConfig.assert_called_once() mock_load_catalog.assert_called_once_with("rest", uri="https://catalog.service", warehouse="example-warehouse") + + +TEST_VIEW_IDENTIFIER = ("default", "my_view") +TEST_VIEW_METADATA: dict[str, Any] = { + "view-uuid": "b30125c8-7284-442c-9aea-15fee620737c", + "format-version": 1, + "location": "s3://warehouse/default/my_view", + "current-version-id": 1, + "versions": [ + { + "version-id": 1, + "timestamp-ms": 1602638573874, + "schema-id": 1, + "summary": {}, + "representations": [{"type": "sql", "sql": "SELECT * FROM my_table", "dialect": "spark"}], + "default-namespace": ["default"], + } + ], + "schemas": [ + { + "type": "struct", + "schema-id": 1, + "fields": [ + {"id": 1, "name": "x", "required": True, "type": "long"}, + ], + } + ], + "version-log": [{"timestamp-ms": 1602638573874, "version-id": 1}], + "properties": {}, +} + + +@pytest.fixture(name="catalog_with_view") +def fixture_catalog_with_view(mocker: MockFixture, catalog: InMemoryCatalog) -> tuple[InMemoryCatalog, View]: + view = View(TEST_VIEW_IDENTIFIER, ViewMetadata.model_validate(TEST_VIEW_METADATA)) + catalog.list_views = MagicMock(return_value=[TEST_VIEW_IDENTIFIER]) # type: ignore + catalog.load_view = MagicMock(return_value=view) # type: ignore + catalog.drop_view = MagicMock() # type: ignore + return catalog, view + + +def test_list_views(catalog_with_view: tuple[InMemoryCatalog, View]) -> None: + catalog, _ = catalog_with_view + + runner = CliRunner() + result = runner.invoke(run, ["list-views", "default"]) + assert result.exit_code == 0 + assert "default.my_view" in result.output + + +def test_list_views_does_not_exist(catalog: InMemoryCatalog) -> None: + catalog.list_views = MagicMock(side_effect=NoSuchViewError("Namespace does not exist: doesnotexist")) # type: ignore + + runner = CliRunner() + result = runner.invoke(run, ["list-views", "doesnotexist"]) + assert result.exit_code == 1 + assert "Namespace does not exist: doesnotexist" in result.output + + +def test_describe_view(catalog_with_view: tuple[InMemoryCatalog, View]) -> None: + runner = CliRunner() + result = runner.invoke(run, ["describe", "--entity=view", "default.my_view"]) + assert result.exit_code == 0 + assert "b30125c8-7284-442c-9aea-15fee620737c" in result.output + assert "SELECT * FROM my_table" in result.output + + +def test_describe_view_does_not_exist(catalog: InMemoryCatalog) -> None: + catalog.load_view = MagicMock(side_effect=NoSuchViewError("View does not exist: default.doesnotexist")) # type: ignore + + runner = CliRunner() + result = runner.invoke(run, ["describe", "--entity=view", "default.doesnotexist"]) + assert result.exit_code == 1 + assert "View does not exist: default.doesnotexist" in result.output + + +def test_describe_any_falls_through_to_view(catalog_with_view: tuple[InMemoryCatalog, View]) -> None: + catalog, _ = catalog_with_view + catalog.load_table = MagicMock(side_effect=NoSuchTableError("Table does not exist: default.my_view")) # type: ignore + + runner = CliRunner() + result = runner.invoke(run, ["describe", "default.my_view"]) + assert result.exit_code == 0 + assert "b30125c8-7284-442c-9aea-15fee620737c" in result.output + + +def test_drop_view(catalog_with_view: tuple[InMemoryCatalog, View]) -> None: + catalog, _ = catalog_with_view + + runner = CliRunner() + result = runner.invoke(run, ["drop", "view", "default.my_view"]) + assert result.exit_code == 0 + assert result.output == "Dropped view: default.my_view\n" + catalog.drop_view.assert_called_once_with("default.my_view") # type: ignore + + +def test_drop_view_does_not_exist(catalog: InMemoryCatalog) -> None: + catalog.drop_view = MagicMock(side_effect=NoSuchViewError("View does not exist: default.doesnotexist")) # type: ignore + + runner = CliRunner() + result = runner.invoke(run, ["drop", "view", "default.doesnotexist"]) + assert result.exit_code == 1 + assert "View does not exist: default.doesnotexist" in result.output + + +def test_json_list_views(catalog_with_view: tuple[InMemoryCatalog, View]) -> None: + runner = CliRunner() + result = runner.invoke(run, ["--output=json", "list-views", "default"]) + assert result.exit_code == 0 + assert result.output == '["default.my_view"]\n' + + +def test_json_describe_view(catalog_with_view: tuple[InMemoryCatalog, View]) -> None: + runner = CliRunner() + result = runner.invoke(run, ["--output=json", "describe", "--entity=view", "default.my_view"]) + assert result.exit_code == 0 + assert "b30125c8-7284-442c-9aea-15fee620737c" in result.output + + +def test_json_drop_view(catalog_with_view: tuple[InMemoryCatalog, View]) -> None: + runner = CliRunner() + result = runner.invoke(run, ["--output=json", "drop", "view", "default.my_view"]) + assert result.exit_code == 0 + assert result.output == '"Dropped view: default.my_view"\n' + + +def test_json_drop_view_does_not_exist(catalog: InMemoryCatalog) -> None: + catalog.drop_view = MagicMock(side_effect=NoSuchViewError("View does not exist: default.doesnotexist")) # type: ignore + + runner = CliRunner() + result = runner.invoke(run, ["--output=json", "drop", "view", "default.doesnotexist"]) + assert result.exit_code == 1 + assert result.output == '{"type": "NoSuchViewError", "message": "View does not exist: default.doesnotexist"}\n'