Add view operations to CLI (#3926)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
diff --git a/pyiceberg/cli/console.py b/pyiceberg/cli/console.py
index b6ab56d..9f8bed5 100644
--- a/pyiceberg/cli/console.py
+++ b/pyiceberg/cli/console.py
@@ -29,12 +29,13 @@
 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 Table, TableProperties
 from pyiceberg.table.refs import SnapshotRef, SnapshotRefType
 from pyiceberg.typedef import Properties
 from pyiceberg.utils.properties import property_as_int
+from pyiceberg.view import View
 
 
 def catch_exception() -> Callable:  # type: ignore
@@ -145,15 +146,15 @@
 @run.command()
 @click.option(
     "--entity",
-    type=click.Choice(["any", "namespace", "table"]),
+    type=click.Choice(["any", "namespace", "table", "view"]),
     default="any",
     help="Entity type. 'any' auto-detects and requires --entity when ambiguous.",
 )
 @click.argument("identifier")
 @click.pass_context
 @catch_exception()
-def describe(ctx: Context, entity: Literal["any", "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)
 
@@ -163,6 +164,9 @@
     if entity == "table":
         output.describe_table(catalog.load_table(identifier))
         return
+    if entity == "view":
+        output.describe_view(catalog.load_view(identifier))
+        return
 
     # For the default "any" entity, auto-detect the entity type.
     if len(identifier_tuple) == 1:
@@ -172,6 +176,7 @@
     matches: tuple[str, ...] = ()
     namespace_properties: Properties | None = None
     catalog_table: Table | None = None
+    catalog_view: View | None = None
 
     try:
         namespace_properties = catalog.load_namespace_properties(identifier_tuple)
@@ -185,19 +190,29 @@
     except NoSuchTableError:
         pass
 
+    try:
+        catalog_view = catalog.load_view(identifier)
+        matches += ("view",)
+    except (NoSuchViewError, NotImplementedError):
+        pass
+
     if len(matches) > 1:
         raise ValueError(
             f"Identifier {identifier} matches multiple entity types: {', '.join(matches)}. Use --entity to disambiguate."
         )
     if not matches:
-        raise NoSuchTableError(f"Table or namespace does not exist: {identifier}")
+        raise NoSuchTableError(f"Table, view, or namespace does not exist: {identifier}")
 
-    if matches[0] == "namespace":
-        assert namespace_properties is not None
-        output.describe_properties(namespace_properties)
-    else:
-        assert catalog_table is not None
-        output.describe_table(catalog_table)
+    match matches[0]:
+        case "namespace":
+            assert namespace_properties is not None
+            output.describe_properties(namespace_properties)
+        case "table":
+            assert catalog_table is not None
+            output.describe_table(catalog_table)
+        case "view":
+            assert catalog_view is not None
+            output.describe_view(catalog_view)
 
 
 @run.command()
@@ -321,6 +336,18 @@
     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")
@@ -466,6 +493,17 @@
 
 
 @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)
 @click.option("--verbose", type=click.BOOL)
diff --git a/pyiceberg/cli/output.py b/pyiceberg/cli/output.py
index 3322210..4a508d5 100644
--- a/pyiceberg/cli/output.py
+++ b/pyiceberg/cli/output.py
@@ -23,6 +23,7 @@
 
 from rich.console import Console
 from rich.table import Table as RichTable
+from rich.text import Text
 from rich.tree import Tree
 
 from pyiceberg.partitioning import PartitionSpec
@@ -31,6 +32,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):
@@ -46,6 +48,9 @@
     def describe_table(self, table: Table) -> None: ...
 
     @abstractmethod
+    def describe_view(self, view: View) -> None: ...
+
+    @abstractmethod
     def files(self, table: Table, history: bool) -> None: ...
 
     @abstractmethod
@@ -123,6 +128,31 @@
         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(Text(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 +246,9 @@
             ).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 52b5b98..2bf6651 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:
@@ -220,7 +224,7 @@
     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"
 
 
 @pytest.mark.parametrize("entity_args", [[], ["--entity", "table"]], ids=["any", "table"])
@@ -737,7 +741,7 @@
     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"""
     )
 
 
@@ -1171,3 +1175,136 @@
     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 "spark: 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'