From 7519e33afc6daa21ef9f9a383340e3e61e3952f4 Mon Sep 17 00:00:00 2001 From: Kevin Liu Date: Fri, 11 Sep 2026 10:41:47 -0700 Subject: [PATCH 1/4] Improve CLI describe entity detection Resolve namespace and table candidates before rendering output so ambiguous identifiers require explicit disambiguation without partial descriptions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- pyiceberg/cli/console.py | 72 ++++++++++++++++++++++++++------------- tests/cli/test_console.py | 36 ++++++++++++++++++-- 2 files changed, 82 insertions(+), 26 deletions(-) diff --git a/pyiceberg/cli/console.py b/pyiceberg/cli/console.py index 6db5340d03..01ceebb897 100644 --- a/pyiceberg/cli/console.py +++ b/pyiceberg/cli/console.py @@ -31,8 +31,9 @@ from pyiceberg.cli.output import ConsoleOutput, JsonOutput, Output from pyiceberg.exceptions import NoSuchNamespaceError, NoSuchPropertyException, NoSuchTableError from pyiceberg.io import WAREHOUSE -from pyiceberg.table import TableProperties +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 @@ -142,38 +143,61 @@ 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"]), + 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["name", "namespace", "table"], identifier: str) -> None: +def describe(ctx: Context, entity: Literal["any", "namespace", "table"], identifier: str) -> None: """Describe a namespace or a table.""" catalog, output = _catalog_and_output(ctx) identifier_tuple = Catalog.identifier_to_tuple(identifier) - is_namespace = False - if entity in {"namespace", "any"} and len(identifier_tuple) > 0: - try: - namespace_properties = catalog.load_namespace_properties(identifier_tuple) - output.describe_properties(namespace_properties) - is_namespace = True - except NoSuchNamespaceError as exc: - if entity != "any" or len(identifier_tuple) == 1: # type: ignore - raise exc - - is_table = False - if entity in {"table", "any"} and len(identifier_tuple) > 1: - try: - catalog_table = catalog.load_table(identifier) - output.describe_table(catalog_table) - is_table = True - except NoSuchTableError as exc: - if entity != "any": - raise exc - - if is_namespace is False and is_table is False: + if entity == "namespace": + output.describe_properties(catalog.load_namespace_properties(identifier_tuple)) + return + if entity == "table": + output.describe_table(catalog.load_table(identifier)) + return + + if len(identifier_tuple) == 1: + output.describe_properties(catalog.load_namespace_properties(identifier_tuple)) + return + + matches: tuple[str, ...] = () + namespace_properties: Properties | None = None + catalog_table: Table | None = None + + try: + namespace_properties = catalog.load_namespace_properties(identifier_tuple) + matches += ("namespace",) + except NoSuchNamespaceError: + pass + + try: + catalog_table = catalog.load_table(identifier) + matches += ("table",) + except NoSuchTableError: + 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}") + 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) + @run.command() @click.argument("identifier") diff --git a/tests/cli/test_console.py b/tests/cli/test_console.py index ebc996a59b..ad61d1af74 100644 --- a/tests/cli/test_console.py +++ b/tests/cli/test_console.py @@ -159,11 +159,12 @@ def test_list_namespace(catalog: InMemoryCatalog) -> None: assert result.output == "default.my_table\n" -def test_describe_namespace(catalog: InMemoryCatalog, namespace_properties: Properties) -> None: +@pytest.mark.parametrize("entity_args", [[], ["--entity", "namespace"]], ids=["any", "namespace"]) +def test_describe_namespace(catalog: InMemoryCatalog, namespace_properties: Properties, entity_args: list[str]) -> None: catalog.create_namespace(TEST_TABLE_NAMESPACE, namespace_properties) runner = CliRunner() - result = runner.invoke(run, ["describe", "default"]) + result = runner.invoke(run, ["describe", *entity_args, "default"]) assert result.exit_code == 0 assert result.output == "location s3://warehouse/database/location\n" @@ -222,6 +223,37 @@ def test_describe_table_does_not_exists(catalog: InMemoryCatalog) -> None: assert result.output == "Table or namespace does not exist: default.doesnotexist\n" +@pytest.mark.parametrize("entity_args", [[], ["--entity", "table"]], ids=["any", "table"]) +def test_describe_table_entity_detection(catalog: InMemoryCatalog, mock_datetime_now: None, entity_args: list[str]) -> None: + catalog.create_namespace(TEST_TABLE_NAMESPACE) + catalog.create_table( + identifier=TEST_TABLE_IDENTIFIER, + schema=TEST_TABLE_SCHEMA, + partition_spec=TEST_TABLE_PARTITION_SPEC, + ) + + runner = CliRunner() + result = runner.invoke(run, ["describe", *entity_args, "default.my_table"]) + + assert result.exit_code == 0 + assert "Table UUID" in result.output + assert "Current schema" in result.output + + +def test_describe_ambiguous_entity(catalog: InMemoryCatalog, namespace_properties: Properties) -> None: + catalog.create_namespace(TEST_TABLE_NAMESPACE) + catalog.create_table(identifier=TEST_TABLE_IDENTIFIER, schema=TEST_TABLE_SCHEMA) + catalog.create_namespace(TEST_TABLE_IDENTIFIER, namespace_properties) + + runner = CliRunner() + result = runner.invoke(run, ["describe", "default.my_table"]) + assert result.exit_code == 1 + assert result.exit_code == 1 + assert " ".join(result.output.split()) == ( + "Identifier default.my_table matches multiple entity types: namespace, table. Use --entity to disambiguate." + ) + + def test_schema(catalog: InMemoryCatalog) -> None: catalog.create_namespace(TEST_TABLE_NAMESPACE) catalog.create_table( From 7ec1ef8a96f731484561ac58309e3f3325922b8e Mon Sep 17 00:00:00 2001 From: Kevin Liu Date: Fri, 11 Sep 2026 10:42:01 -0700 Subject: [PATCH 2/4] Remove duplicate ambiguity assertion Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/cli/test_console.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/cli/test_console.py b/tests/cli/test_console.py index ad61d1af74..52b5b98810 100644 --- a/tests/cli/test_console.py +++ b/tests/cli/test_console.py @@ -248,7 +248,6 @@ def test_describe_ambiguous_entity(catalog: InMemoryCatalog, namespace_propertie runner = CliRunner() result = runner.invoke(run, ["describe", "default.my_table"]) assert result.exit_code == 1 - assert result.exit_code == 1 assert " ".join(result.output.split()) == ( "Identifier default.my_table matches multiple entity types: namespace, table. Use --entity to disambiguate." ) From 554b69155c952cc26a4bee2ca9e2ae05b8577019 Mon Sep 17 00:00:00 2001 From: Kevin Liu Date: Fri, 11 Sep 2026 10:52:56 -0700 Subject: [PATCH 3/4] Fix BigQuery namespace miss handling Report unsupported multipart namespace identifiers as NoSuchNamespaceError so CLI describe can continue to the table candidate without hiding unexpected catalog errors. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- pyiceberg/catalog/bigquery_metastore.py | 2 +- tests/catalog/test_bigquery_metastore.py | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/pyiceberg/catalog/bigquery_metastore.py b/pyiceberg/catalog/bigquery_metastore.py index 938ac6992f..cc84b0b420 100644 --- a/pyiceberg/catalog/bigquery_metastore.py +++ b/pyiceberg/catalog/bigquery_metastore.py @@ -335,7 +335,7 @@ def load_view(self, identifier: str | Identifier) -> View: @override def load_namespace_properties(self, namespace: str | Identifier) -> Properties: - dataset_name = self.identifier_to_database(namespace) + dataset_name = self.identifier_to_database(namespace, NoSuchNamespaceError) try: dataset = self.client.get_dataset(DatasetReference(project=self.project_id, dataset_id=dataset_name)) diff --git a/tests/catalog/test_bigquery_metastore.py b/tests/catalog/test_bigquery_metastore.py index c8c7584262..df40417ff1 100644 --- a/tests/catalog/test_bigquery_metastore.py +++ b/tests/catalog/test_bigquery_metastore.py @@ -17,13 +17,14 @@ import os from unittest.mock import MagicMock +import pytest from google.api_core.exceptions import NotFound from google.cloud.bigquery import Dataset, DatasetReference, Table, TableReference from google.cloud.bigquery.external_config import ExternalCatalogDatasetOptions, ExternalCatalogTableOptions from pytest_mock import MockFixture from pyiceberg.catalog.bigquery_metastore import ICEBERG_TABLE_TYPE_VALUE, TABLE_TYPE_PROP, BigQueryMetastoreCatalog -from pyiceberg.exceptions import NoSuchTableError +from pyiceberg.exceptions import NoSuchNamespaceError, NoSuchTableError from pyiceberg.schema import Schema @@ -178,3 +179,15 @@ def test_list_namespaces(mocker: MockFixture) -> None: assert ("dataset1",) in namespaces assert ("dataset2",) in namespaces client_mock.list_datasets.assert_called_once() + + +def test_load_namespace_properties_rejects_multipart_namespace(mocker: MockFixture) -> None: + client_mock = MagicMock() + mocker.patch("pyiceberg.catalog.bigquery_metastore.Client", return_value=client_mock) + mocker.patch.dict(os.environ, values={"PYICEBERG_LEGACY_CURRENT_SNAPSHOT_ID": "True"}) + catalog = BigQueryMetastoreCatalog("test_catalog", **{"gcp.bigquery.project-id": "my-project"}) + + with pytest.raises(NoSuchNamespaceError, match="hierarchical namespaces are not supported"): + catalog.load_namespace_properties(("dataset", "table")) + + client_mock.get_dataset.assert_not_called() From 786e0c5212cbc3ac3a647b265287f0d706c36dbf Mon Sep 17 00:00:00 2001 From: Kevin Liu Date: Fri, 11 Sep 2026 12:45:53 -0700 Subject: [PATCH 4/4] Apply batched suggestions from code review Co-authored-by: Kevin Liu --- pyiceberg/cli/console.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pyiceberg/cli/console.py b/pyiceberg/cli/console.py index 01ceebb897..b6ab56d01f 100644 --- a/pyiceberg/cli/console.py +++ b/pyiceberg/cli/console.py @@ -164,6 +164,7 @@ def describe(ctx: Context, entity: Literal["any", "namespace", "table"], identif output.describe_table(catalog.load_table(identifier)) return + # For the default "any" entity, auto-detect the entity type. if len(identifier_tuple) == 1: output.describe_properties(catalog.load_namespace_properties(identifier_tuple)) return