Improve CLI describe entity detection (#3934)
* 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>
* Remove duplicate ambiguity assertion
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
* 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>
* Apply batched suggestions from code review
Co-authored-by: Kevin Liu <kevinjqliu@users.noreply.github.com>
---------
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
diff --git a/pyiceberg/catalog/bigquery_metastore.py b/pyiceberg/catalog/bigquery_metastore.py
index 938ac69..cc84b0b 100644
--- a/pyiceberg/catalog/bigquery_metastore.py
+++ b/pyiceberg/catalog/bigquery_metastore.py
@@ -335,7 +335,7 @@
@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/pyiceberg/cli/console.py b/pyiceberg/cli/console.py
index 6db5340..b6ab56d 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,62 @@
@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
+ if entity == "namespace":
+ output.describe_properties(catalog.load_namespace_properties(identifier_tuple))
+ return
+ if entity == "table":
+ output.describe_table(catalog.load_table(identifier))
+ return
- 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
+ # 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
- if is_namespace is False and is_table is False:
+ 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/catalog/test_bigquery_metastore.py b/tests/catalog/test_bigquery_metastore.py
index c8c7584..df40417 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 @@
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()
diff --git a/tests/cli/test_console.py b/tests/cli/test_console.py
index ebc996a..52b5b98 100644
--- a/tests/cli/test_console.py
+++ b/tests/cli/test_console.py
@@ -159,11 +159,12 @@
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,36 @@
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 " ".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(