Skip to content

Commit 76eb1d4

Browse files
ebyhrCopilot
andauthored
Add view operations to CLI (#3926)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 0e4ac51 commit 76eb1d4

3 files changed

Lines changed: 221 additions & 13 deletions

File tree

pyiceberg/cli/console.py

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,13 @@
2929
from pyiceberg import __version__
3030
from pyiceberg.catalog import URI, Catalog, load_catalog
3131
from pyiceberg.cli.output import ConsoleOutput, JsonOutput, Output
32-
from pyiceberg.exceptions import NoSuchNamespaceError, NoSuchPropertyException, NoSuchTableError
32+
from pyiceberg.exceptions import NoSuchNamespaceError, NoSuchPropertyException, NoSuchTableError, NoSuchViewError
3333
from pyiceberg.io import WAREHOUSE
3434
from pyiceberg.table import Table, TableProperties
3535
from pyiceberg.table.refs import SnapshotRef, SnapshotRefType
3636
from pyiceberg.typedef import Properties
3737
from pyiceberg.utils.properties import property_as_int
38+
from pyiceberg.view import View
3839

3940

4041
def catch_exception() -> Callable: # type: ignore
@@ -145,15 +146,15 @@ def list(ctx: Context, parent: str | None) -> None: # pylint: disable=redefined
145146
@run.command()
146147
@click.option(
147148
"--entity",
148-
type=click.Choice(["any", "namespace", "table"]),
149+
type=click.Choice(["any", "namespace", "table", "view"]),
149150
default="any",
150151
help="Entity type. 'any' auto-detects and requires --entity when ambiguous.",
151152
)
152153
@click.argument("identifier")
153154
@click.pass_context
154155
@catch_exception()
155-
def describe(ctx: Context, entity: Literal["any", "namespace", "table"], identifier: str) -> None:
156-
"""Describe a namespace or a table."""
156+
def describe(ctx: Context, entity: Literal["any", "namespace", "table", "view"], identifier: str) -> None:
157+
"""Describe a namespace, a table, or a view."""
157158
catalog, output = _catalog_and_output(ctx)
158159
identifier_tuple = Catalog.identifier_to_tuple(identifier)
159160

@@ -163,6 +164,9 @@ def describe(ctx: Context, entity: Literal["any", "namespace", "table"], identif
163164
if entity == "table":
164165
output.describe_table(catalog.load_table(identifier))
165166
return
167+
if entity == "view":
168+
output.describe_view(catalog.load_view(identifier))
169+
return
166170

167171
# For the default "any" entity, auto-detect the entity type.
168172
if len(identifier_tuple) == 1:
@@ -172,6 +176,7 @@ def describe(ctx: Context, entity: Literal["any", "namespace", "table"], identif
172176
matches: tuple[str, ...] = ()
173177
namespace_properties: Properties | None = None
174178
catalog_table: Table | None = None
179+
catalog_view: View | None = None
175180

176181
try:
177182
namespace_properties = catalog.load_namespace_properties(identifier_tuple)
@@ -185,19 +190,29 @@ def describe(ctx: Context, entity: Literal["any", "namespace", "table"], identif
185190
except NoSuchTableError:
186191
pass
187192

193+
try:
194+
catalog_view = catalog.load_view(identifier)
195+
matches += ("view",)
196+
except (NoSuchViewError, NotImplementedError):
197+
pass
198+
188199
if len(matches) > 1:
189200
raise ValueError(
190201
f"Identifier {identifier} matches multiple entity types: {', '.join(matches)}. Use --entity to disambiguate."
191202
)
192203
if not matches:
193-
raise NoSuchTableError(f"Table or namespace does not exist: {identifier}")
204+
raise NoSuchTableError(f"Table, view, or namespace does not exist: {identifier}")
194205

195-
if matches[0] == "namespace":
196-
assert namespace_properties is not None
197-
output.describe_properties(namespace_properties)
198-
else:
199-
assert catalog_table is not None
200-
output.describe_table(catalog_table)
206+
match matches[0]:
207+
case "namespace":
208+
assert namespace_properties is not None
209+
output.describe_properties(namespace_properties)
210+
case "table":
211+
assert catalog_table is not None
212+
output.describe_table(catalog_table)
213+
case "view":
214+
assert catalog_view is not None
215+
output.describe_view(catalog_view)
201216

202217

203218
@run.command()
@@ -321,6 +336,18 @@ def namespace(ctx: Context, identifier: str) -> None: # noqa: F811
321336
output.text(f"Dropped namespace: {identifier}")
322337

323338

339+
@drop.command()
340+
@click.argument("identifier")
341+
@click.pass_context
342+
@catch_exception()
343+
def view(ctx: Context, identifier: str) -> None: # noqa: F811
344+
"""Drop a view."""
345+
catalog, output = _catalog_and_output(ctx)
346+
347+
catalog.drop_view(identifier)
348+
output.text(f"Dropped view: {identifier}")
349+
350+
324351
@run.command()
325352
@click.argument("from_identifier")
326353
@click.argument("to_identifier")
@@ -465,6 +492,17 @@ def table(ctx: Context, identifier: str, property_name: str) -> None: # noqa: F
465492
raise NoSuchPropertyException(f"Property {property_name} does not exist on {identifier}")
466493

467494

495+
@run.command()
496+
@click.argument("namespace")
497+
@click.pass_context
498+
@catch_exception()
499+
def list_views(ctx: Context, namespace: str) -> None:
500+
"""List all views in a namespace."""
501+
catalog, output = _catalog_and_output(ctx)
502+
identifiers = catalog.list_views(namespace)
503+
output.identifiers(identifiers)
504+
505+
468506
@run.command()
469507
@click.argument("identifier")
470508
@click.option("--type", required=False)

pyiceberg/cli/output.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323

2424
from rich.console import Console
2525
from rich.table import Table as RichTable
26+
from rich.text import Text
2627
from rich.tree import Tree
2728

2829
from pyiceberg.partitioning import PartitionSpec
@@ -31,6 +32,7 @@
3132
from pyiceberg.table.metadata import TableMetadata
3233
from pyiceberg.table.refs import SnapshotRefType
3334
from pyiceberg.typedef import IcebergBaseModel, Identifier, Properties
35+
from pyiceberg.view import View
3436

3537

3638
class Output(ABC):
@@ -45,6 +47,9 @@ def identifiers(self, identifiers: list[Identifier]) -> None: ...
4547
@abstractmethod
4648
def describe_table(self, table: Table) -> None: ...
4749

50+
@abstractmethod
51+
def describe_view(self, view: View) -> None: ...
52+
4853
@abstractmethod
4954
def files(self, table: Table, history: bool) -> None: ...
5055

@@ -123,6 +128,31 @@ def describe_table(self, table: Table) -> None:
123128
output_table.add_row("Properties", table_properties)
124129
Console().print(output_table)
125130

131+
def describe_view(self, view: View) -> None:
132+
metadata = view.metadata
133+
view_properties = self._table
134+
for key, value in metadata.properties.items():
135+
view_properties.add_row(key, value)
136+
137+
schema_tree = Tree(f"Schema, id={view.current_version().schema_id}")
138+
for field in view.schema().fields:
139+
schema_tree.add(str(field))
140+
141+
current_version = view.current_version()
142+
representations_tree = Tree("SQL representations")
143+
for repr in current_version.representations:
144+
representations_tree.add(Text(f"{repr.root.dialect}: {repr.root.sql}"))
145+
146+
output_table = self._table
147+
output_table.add_row("View format version", str(metadata.format_version))
148+
output_table.add_row("View UUID", str(metadata.view_uuid))
149+
output_table.add_row("Location", metadata.location)
150+
output_table.add_row("Current version", str(metadata.current_version_id))
151+
output_table.add_row("Current schema", schema_tree)
152+
output_table.add_row("SQL", representations_tree)
153+
output_table.add_row("Properties", view_properties)
154+
Console().print(output_table)
155+
126156
def files(self, table: Table, history: bool) -> None:
127157
if history:
128158
snapshots = table.metadata.snapshots
@@ -216,6 +246,9 @@ class FauxTable(IcebergBaseModel):
216246
).model_dump_json()
217247
)
218248

249+
def describe_view(self, view: View) -> None:
250+
print(view.metadata.model_dump_json())
251+
219252
def describe_properties(self, properties: Properties) -> None:
220253
self._out(properties)
221254

tests/cli/test_console.py

Lines changed: 139 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import os
1919
import uuid
2020
from pathlib import PosixPath
21+
from typing import Any
2122
from unittest import mock
2223
from unittest.mock import MagicMock
2324

@@ -28,13 +29,16 @@
2829
from pyiceberg import __version__
2930
from pyiceberg.catalog.memory import InMemoryCatalog
3031
from pyiceberg.cli.console import run
32+
from pyiceberg.exceptions import NoSuchTableError, NoSuchViewError
3133
from pyiceberg.io import WAREHOUSE
3234
from pyiceberg.partitioning import PartitionField, PartitionSpec
3335
from pyiceberg.schema import Schema
3436
from pyiceberg.transforms import IdentityTransform
3537
from pyiceberg.typedef import Properties
3638
from pyiceberg.types import LongType, NestedField
3739
from pyiceberg.utils.config import Config
40+
from pyiceberg.view import View
41+
from pyiceberg.view.metadata import ViewMetadata
3842

3943

4044
def test_missing_uri(mocker: MockFixture, empty_home_dir_path: str) -> None:
@@ -220,7 +224,7 @@ def test_describe_table_does_not_exists(catalog: InMemoryCatalog) -> None:
220224
runner = CliRunner()
221225
result = runner.invoke(run, ["describe", "default.doesnotexist"])
222226
assert result.exit_code == 1
223-
assert result.output == "Table or namespace does not exist: default.doesnotexist\n"
227+
assert result.output == "Table, view, or namespace does not exist: default.doesnotexist\n"
224228

225229

226230
@pytest.mark.parametrize("entity_args", [[], ["--entity", "table"]], ids=["any", "table"])
@@ -737,7 +741,7 @@ def test_json_describe_table_does_not_exists(catalog: InMemoryCatalog) -> None:
737741
assert result.exit_code == 1
738742
assert (
739743
result.output
740-
== """{"type": "NoSuchTableError", "message": "Table or namespace does not exist: default.doesnotexist"}\n"""
744+
== """{"type": "NoSuchTableError", "message": "Table, view, or namespace does not exist: default.doesnotexist"}\n"""
741745
)
742746

743747

@@ -1171,3 +1175,136 @@ def test_warehouse_cli_option_forwarded_to_catalog(mocker: MockFixture) -> None:
11711175
assert result.exit_code == 0
11721176
mock_basicConfig.assert_called_once()
11731177
mock_load_catalog.assert_called_once_with("rest", uri="https://catalog.service", warehouse="example-warehouse")
1178+
1179+
1180+
TEST_VIEW_IDENTIFIER = ("default", "my_view")
1181+
TEST_VIEW_METADATA: dict[str, Any] = {
1182+
"view-uuid": "b30125c8-7284-442c-9aea-15fee620737c",
1183+
"format-version": 1,
1184+
"location": "s3://warehouse/default/my_view",
1185+
"current-version-id": 1,
1186+
"versions": [
1187+
{
1188+
"version-id": 1,
1189+
"timestamp-ms": 1602638573874,
1190+
"schema-id": 1,
1191+
"summary": {},
1192+
"representations": [{"type": "sql", "sql": "SELECT * FROM my_table", "dialect": "spark"}],
1193+
"default-namespace": ["default"],
1194+
}
1195+
],
1196+
"schemas": [
1197+
{
1198+
"type": "struct",
1199+
"schema-id": 1,
1200+
"fields": [
1201+
{"id": 1, "name": "x", "required": True, "type": "long"},
1202+
],
1203+
}
1204+
],
1205+
"version-log": [{"timestamp-ms": 1602638573874, "version-id": 1}],
1206+
"properties": {},
1207+
}
1208+
1209+
1210+
@pytest.fixture(name="catalog_with_view")
1211+
def fixture_catalog_with_view(mocker: MockFixture, catalog: InMemoryCatalog) -> tuple[InMemoryCatalog, View]:
1212+
view = View(TEST_VIEW_IDENTIFIER, ViewMetadata.model_validate(TEST_VIEW_METADATA))
1213+
catalog.list_views = MagicMock(return_value=[TEST_VIEW_IDENTIFIER]) # type: ignore
1214+
catalog.load_view = MagicMock(return_value=view) # type: ignore
1215+
catalog.drop_view = MagicMock() # type: ignore
1216+
return catalog, view
1217+
1218+
1219+
def test_list_views(catalog_with_view: tuple[InMemoryCatalog, View]) -> None:
1220+
catalog, _ = catalog_with_view
1221+
1222+
runner = CliRunner()
1223+
result = runner.invoke(run, ["list-views", "default"])
1224+
assert result.exit_code == 0
1225+
assert "default.my_view" in result.output
1226+
1227+
1228+
def test_list_views_does_not_exist(catalog: InMemoryCatalog) -> None:
1229+
catalog.list_views = MagicMock(side_effect=NoSuchViewError("Namespace does not exist: doesnotexist")) # type: ignore
1230+
1231+
runner = CliRunner()
1232+
result = runner.invoke(run, ["list-views", "doesnotexist"])
1233+
assert result.exit_code == 1
1234+
assert "Namespace does not exist: doesnotexist" in result.output
1235+
1236+
1237+
def test_describe_view(catalog_with_view: tuple[InMemoryCatalog, View]) -> None:
1238+
runner = CliRunner()
1239+
result = runner.invoke(run, ["describe", "--entity=view", "default.my_view"])
1240+
assert result.exit_code == 0
1241+
assert "b30125c8-7284-442c-9aea-15fee620737c" in result.output
1242+
assert "spark: SELECT * FROM my_table" in result.output
1243+
1244+
1245+
def test_describe_view_does_not_exist(catalog: InMemoryCatalog) -> None:
1246+
catalog.load_view = MagicMock(side_effect=NoSuchViewError("View does not exist: default.doesnotexist")) # type: ignore
1247+
1248+
runner = CliRunner()
1249+
result = runner.invoke(run, ["describe", "--entity=view", "default.doesnotexist"])
1250+
assert result.exit_code == 1
1251+
assert "View does not exist: default.doesnotexist" in result.output
1252+
1253+
1254+
def test_describe_any_falls_through_to_view(catalog_with_view: tuple[InMemoryCatalog, View]) -> None:
1255+
catalog, _ = catalog_with_view
1256+
catalog.load_table = MagicMock(side_effect=NoSuchTableError("Table does not exist: default.my_view")) # type: ignore
1257+
1258+
runner = CliRunner()
1259+
result = runner.invoke(run, ["describe", "default.my_view"])
1260+
assert result.exit_code == 0
1261+
assert "b30125c8-7284-442c-9aea-15fee620737c" in result.output
1262+
1263+
1264+
def test_drop_view(catalog_with_view: tuple[InMemoryCatalog, View]) -> None:
1265+
catalog, _ = catalog_with_view
1266+
1267+
runner = CliRunner()
1268+
result = runner.invoke(run, ["drop", "view", "default.my_view"])
1269+
assert result.exit_code == 0
1270+
assert result.output == "Dropped view: default.my_view\n"
1271+
catalog.drop_view.assert_called_once_with("default.my_view") # type: ignore
1272+
1273+
1274+
def test_drop_view_does_not_exist(catalog: InMemoryCatalog) -> None:
1275+
catalog.drop_view = MagicMock(side_effect=NoSuchViewError("View does not exist: default.doesnotexist")) # type: ignore
1276+
1277+
runner = CliRunner()
1278+
result = runner.invoke(run, ["drop", "view", "default.doesnotexist"])
1279+
assert result.exit_code == 1
1280+
assert "View does not exist: default.doesnotexist" in result.output
1281+
1282+
1283+
def test_json_list_views(catalog_with_view: tuple[InMemoryCatalog, View]) -> None:
1284+
runner = CliRunner()
1285+
result = runner.invoke(run, ["--output=json", "list-views", "default"])
1286+
assert result.exit_code == 0
1287+
assert result.output == '["default.my_view"]\n'
1288+
1289+
1290+
def test_json_describe_view(catalog_with_view: tuple[InMemoryCatalog, View]) -> None:
1291+
runner = CliRunner()
1292+
result = runner.invoke(run, ["--output=json", "describe", "--entity=view", "default.my_view"])
1293+
assert result.exit_code == 0
1294+
assert "b30125c8-7284-442c-9aea-15fee620737c" in result.output
1295+
1296+
1297+
def test_json_drop_view(catalog_with_view: tuple[InMemoryCatalog, View]) -> None:
1298+
runner = CliRunner()
1299+
result = runner.invoke(run, ["--output=json", "drop", "view", "default.my_view"])
1300+
assert result.exit_code == 0
1301+
assert result.output == '"Dropped view: default.my_view"\n'
1302+
1303+
1304+
def test_json_drop_view_does_not_exist(catalog: InMemoryCatalog) -> None:
1305+
catalog.drop_view = MagicMock(side_effect=NoSuchViewError("View does not exist: default.doesnotexist")) # type: ignore
1306+
1307+
runner = CliRunner()
1308+
result = runner.invoke(run, ["--output=json", "drop", "view", "default.doesnotexist"])
1309+
assert result.exit_code == 1
1310+
assert result.output == '{"type": "NoSuchViewError", "message": "View does not exist: default.doesnotexist"}\n'

0 commit comments

Comments
 (0)