This is an automated email from the ASF dual-hosted git repository.
mchades pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/main by this push:
new e6f59c6184 [#14146] feat(client-python): Support Semantic Model metric
discovery (#14147)
e6f59c6184 is described below
commit e6f59c618451dcd9762c7422bc94d30c1237b91a
Author: mchades <[email protected]>
AuthorDate: Thu Oct 29 13:45:48 2026 +0800
[#14146] feat(client-python): Support Semantic Model metric discovery
(#14147)
### What changes were proposed in this pull request?
Add `list_metrics` and `list_metric_infos` to the Python
`SemanticModelCatalog` API, with a `SemanticModelMetric` result type and
REST response decoding. Each call uses one schema-level request and
preserves complete metrics, including Decimal precision.
### Why are the changes needed?
Let Python clients discover metrics without loading each Semantic Model,
following #14038 under #14036.
Builds on #14143, now merged into `main`.
Fixes #14146.
### Does this PR introduce _any_ user-facing change?
Adds two discovery methods with catalog-relative schema/model
namespaces. Existing `SemanticModelCatalog` implementations retain
compatible unsupported-operation defaults.
### How was this patch tested?
138 Semantic Model unit tests and 14 real-server integration tests
passed on the rebased branch with Python 3.12 and H2, covering discovery
lifecycle, authorization, and Ossie regressions.
From `clients/client-python`:
```sh
python -m unittest discover -s tests/unittests -t . -p '*semantic*.py'
python -m unittest -v
tests.integration.test_semantic_model_metric_discovery
tests.integration.test_semantic_model_authorization
tests.integration.test_semantic_model_ossie
```
Integration tests used `GRAVITINO_HOME` and
`START_EXTERNAL_GRAVITINO=true` against the freshly built server.
`./gradlew compileDistribution spotlessApply -PskipWeb=true -x test`
passed. Black and Pylint passed on all nine changed Python files, and
`git diff --check` passed.
---
.../api/semantic/semantic_model_catalog.py | 40 +++
.../api/semantic/semantic_model_metric.py | 56 ++++
.../client/semantic_model_catalog_operations.py | 57 ++++
.../dto/responses/metric_info_list_response.py | 49 +++
.../dto/semantic/semantic_model_metric_dto.py | 51 +++
.../test_semantic_model_authorization.py | 75 +++++
.../test_semantic_model_metric_discovery.py | 195 +++++++++++
.../api/semantic/test_semantic_model_catalog.py | 11 +
.../test_semantic_model_metric_discovery.py | 367 +++++++++++++++++++++
9 files changed, 901 insertions(+)
diff --git
a/clients/client-python/gravitino/api/semantic/semantic_model_catalog.py
b/clients/client-python/gravitino/api/semantic/semantic_model_catalog.py
index 815781a699..9c0c809493 100644
--- a/clients/client-python/gravitino/api/semantic/semantic_model_catalog.py
+++ b/clients/client-python/gravitino/api/semantic/semantic_model_catalog.py
@@ -21,6 +21,7 @@ from typing import Optional
from gravitino.api.semantic.semantic_model import SemanticModel
from gravitino.api.semantic.semantic_model_change import SemanticModelChange
from gravitino.api.semantic.semantic_model_definition import
SemanticModelDefinition
+from gravitino.api.semantic.semantic_model_metric import SemanticModelMetric
from gravitino.exceptions.base import (
NoSuchSemanticModelException,
UnsupportedOperationException,
@@ -55,6 +56,45 @@ class SemanticModelCatalog(ABC):
NoSuchSchemaException: If the schema does not exist.
"""
+ def list_metrics(self, namespace: Namespace) -> list[NameIdentifier]:
+ """List visible metric identifiers across the Semantic Models in a
schema.
+
+ Identifiers are relative to this catalog. Each identifier's namespace
+ contains the schema and Semantic Model names, and its name is the exact
+ metric name.
+ Results are ordered by model identifier and then case-sensitive metric
name.
+
+ Args:
+ namespace (Namespace): A single-level schema namespace.
+
+ Returns:
+ list[NameIdentifier]: The visible metric identifiers, or an empty
list.
+
+ Raises:
+ NoSuchSchemaException: If the schema does not exist.
+ UnsupportedOperationException: If metric discovery is not
supported.
+ """
+ raise UnsupportedOperationException("Metric listing is not supported")
+
+ def list_metric_infos(self, namespace: Namespace) ->
list[SemanticModelMetric]:
+ """List complete visible metrics across the Semantic Models in a
schema.
+
+ Each result contains a namespace of schema and model names and the
complete
+ metric definition. Visibility and ordering match :meth:`list_metrics`.
+ The request fails as a whole if metric definitions cannot be decoded.
+
+ Args:
+ namespace (Namespace): A single-level schema namespace.
+
+ Returns:
+ list[SemanticModelMetric]: The visible metrics, or an empty list.
+
+ Raises:
+ NoSuchSchemaException: If the schema does not exist.
+ UnsupportedOperationException: If metric discovery is not
supported.
+ """
+ raise UnsupportedOperationException("Metric info listing is not
supported")
+
@abstractmethod
def load_semantic_model(self, identifier: NameIdentifier) -> SemanticModel:
"""Load Semantic Model metadata by `NameIdentifier` from the catalog.
diff --git
a/clients/client-python/gravitino/api/semantic/semantic_model_metric.py
b/clients/client-python/gravitino/api/semantic/semantic_model_metric.py
new file mode 100644
index 0000000000..4d50a72b9c
--- /dev/null
+++ b/clients/client-python/gravitino/api/semantic/semantic_model_metric.py
@@ -0,0 +1,56 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from gravitino.api.semantic.metric import Metric
+from gravitino.namespace import Namespace
+from gravitino.utils.precondition import Precondition
+
+
+class SemanticModelMetric:
+ """A discovered metric with its containing Semantic Model's namespace.
+
+ Catalog-scoped discovery returns a namespace containing the schema and
model
+ names. The metric's name remains part of its complete :class:`Metric`
value.
+ """
+
+ def __init__(self, namespace: Namespace, metric: Metric):
+ Precondition.check_argument(namespace is not None, "namespace must not
be null")
+ Precondition.check_argument(metric is not None, "metric must not be
null")
+ self._namespace = Namespace.of(*namespace.levels())
+ self._metric = metric
+
+ def namespace(self) -> Namespace:
+ """Return a copy of the namespace ending with the containing model's
name."""
+ return Namespace.of(*self._namespace.levels())
+
+ def metric(self) -> Metric:
+ """Return the complete metric definition."""
+ return self._metric
+
+ def __eq__(self, other: object) -> bool:
+ if not isinstance(other, SemanticModelMetric):
+ return False
+ return self._namespace == other._namespace and self._metric ==
other._metric
+
+ def __hash__(self) -> int:
+ return hash((self._namespace, self._metric))
+
+ def __repr__(self) -> str:
+ return (
+ f"SemanticModelMetric(namespace={self._namespace.levels()!r}, "
+ f"metric={self._metric!r})"
+ )
diff --git
a/clients/client-python/gravitino/client/semantic_model_catalog_operations.py
b/clients/client-python/gravitino/client/semantic_model_catalog_operations.py
index 6e90451fef..f0e41ce709 100644
---
a/clients/client-python/gravitino/client/semantic_model_catalog_operations.py
+++
b/clients/client-python/gravitino/client/semantic_model_catalog_operations.py
@@ -28,6 +28,7 @@ from gravitino.api.semantic.semantic_model_change import (
UpdateComment,
)
from gravitino.api.semantic.semantic_model_definition import
SemanticModelDefinition
+from gravitino.api.semantic.semantic_model_metric import SemanticModelMetric
from gravitino.client.generic_semantic_model import GenericSemanticModel
from gravitino.dto.requests.semantic_model_create_request import (
SemanticModelCreateRequest,
@@ -41,6 +42,7 @@ from gravitino.dto.requests.semantic_model_updates_request
import (
)
from gravitino.dto.responses.drop_response import DropResponse
from gravitino.dto.responses.entity_list_response import EntityListResponse
+from gravitino.dto.responses.metric_info_list_response import
MetricInfoListResponse
from gravitino.dto.responses.semantic_model_response import
SemanticModelResponse
from gravitino.dto.semantic.semantic_model_definition_dto import (
SemanticModelDefinitionDTO,
@@ -86,6 +88,42 @@ class SemanticModelCatalogOperations(SemanticModelCatalog):
for ident in entity_list_resp.identifiers()
]
+ def list_metrics(self, namespace: Namespace) -> list[NameIdentifier]:
+ self._check_semantic_model_namespace(namespace)
+ resp = self._rest_client.get(
+ self._format_metric_request_path(
+ self._get_semantic_model_full_namespace(namespace)
+ ),
+ params={"details": "false"},
+ error_handler=SEMANTIC_MODEL_ERROR_HANDLER,
+ )
+ response = EntityListResponse.from_json(resp.body, infer_missing=True)
+ response.validate()
+ return [
+ NameIdentifier.builder(
+ self._relative_metric_namespace(ident.namespace()),
ident.name()
+ )
+ for ident in response.identifiers()
+ ]
+
+ def list_metric_infos(self, namespace: Namespace) ->
list[SemanticModelMetric]:
+ self._check_semantic_model_namespace(namespace)
+ resp = self._rest_client.get(
+ self._format_metric_request_path(
+ self._get_semantic_model_full_namespace(namespace)
+ ),
+ params={"details": "true"},
+ error_handler=SEMANTIC_MODEL_ERROR_HANDLER,
+ )
+ response = MetricInfoListResponse.from_json(resp.body,
infer_missing=True)
+ response.validate()
+ return [
+ SemanticModelMetric(
+ self._relative_metric_namespace(info.namespace()),
info.metric()
+ )
+ for info in response.metrics()
+ ]
+
def load_semantic_model(self, identifier: NameIdentifier) -> SemanticModel:
self._check_semantic_model_name_identifier(identifier)
full_namespace =
self._get_semantic_model_full_namespace(identifier.namespace())
@@ -291,6 +329,25 @@ class SemanticModelCatalogOperations(SemanticModelCatalog):
"/semantic-models"
)
+ @staticmethod
+ def _format_metric_request_path(namespace: Namespace) -> str:
+ return (
+ f"api/metalakes/{encode_string(namespace.level(0))}"
+ f"/catalogs/{encode_string(namespace.level(1))}"
+ f"/schemas/{encode_string(namespace.level(2))}"
+ "/semantic-model-metrics"
+ )
+
+ @staticmethod
+ def _relative_metric_namespace(namespace: Namespace) -> Namespace:
+ level_count = None if namespace is None else namespace.length()
+ if level_count != 4:
+ raise IllegalArgumentException(
+ "Expected metric namespace with 4 levels
(metalake/catalog/schema/model), "
+ f"got {level_count}"
+ )
+ return Namespace.of(namespace.level(2), namespace.level(3))
+
def _get_semantic_model_full_namespace(
self, semantic_model_namespace: Namespace
) -> Namespace:
diff --git
a/clients/client-python/gravitino/dto/responses/metric_info_list_response.py
b/clients/client-python/gravitino/dto/responses/metric_info_list_response.py
new file mode 100644
index 0000000000..5e5d3215ee
--- /dev/null
+++ b/clients/client-python/gravitino/dto/responses/metric_info_list_response.py
@@ -0,0 +1,49 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from dataclasses import dataclass, field
+
+from dataclasses_json import config
+
+from gravitino.dto.responses.base_response import BaseResponse
+from gravitino.dto.semantic.json_serdes.semantic_json_mixin import
SemanticJsonMixin
+from gravitino.dto.semantic.semantic_model_metric_dto import
SemanticModelMetricDTO
+from gravitino.utils.precondition import Precondition
+
+
+@dataclass
+class MetricInfoListResponse(SemanticJsonMixin, BaseResponse):
+ """Response containing complete metrics discovered in a schema."""
+
+ _metrics: list[SemanticModelMetricDTO] = field(
+ metadata=config(field_name="metrics")
+ )
+
+ def metrics(self) -> list[SemanticModelMetricDTO]:
+ """Return the metric discovery results."""
+ return self._metrics
+
+ def validate(self):
+ """Validate the response code and metric result list."""
+ super().validate()
+ Precondition.check_argument(
+ self._metrics is not None, "metrics must not be null"
+ )
+ Precondition.check_argument(
+ all(metric is not None for metric in self._metrics),
+ "metrics must not contain null",
+ )
diff --git
a/clients/client-python/gravitino/dto/semantic/semantic_model_metric_dto.py
b/clients/client-python/gravitino/dto/semantic/semantic_model_metric_dto.py
new file mode 100644
index 0000000000..3667d5eaa1
--- /dev/null
+++ b/clients/client-python/gravitino/dto/semantic/semantic_model_metric_dto.py
@@ -0,0 +1,51 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from dataclasses import dataclass, field
+from typing import Optional
+
+from dataclasses_json import config
+
+from gravitino.api.semantic.metric import Metric
+from gravitino.dto.semantic.json_serdes.semantic_json_mixin import
SemanticJsonMixin
+from gravitino.dto.semantic.metric_dto import MetricDTO
+from gravitino.namespace import Namespace
+from gravitino.utils.precondition import Precondition
+
+
+@dataclass
+class SemanticModelMetricDTO(SemanticJsonMixin):
+ """A metric's containing namespace and complete definition from
discovery."""
+
+ _namespace: Optional[list[str]] = field(
+ default=None, metadata=config(field_name="namespace")
+ )
+ _metric: Optional[MetricDTO] = field(
+ default=None, metadata=config(field_name="metric")
+ )
+
+ def namespace(self) -> Namespace:
+ """Return the namespace ending with the containing model's name."""
+ Precondition.check_argument(
+ self._namespace is not None, "namespace must not be null"
+ )
+ return Namespace.of(*self._namespace)
+
+ def metric(self) -> Metric:
+ """Decode the complete metric definition."""
+ Precondition.check_argument(self._metric is not None, "metric must not
be null")
+ return self._metric.to_metric()
diff --git
a/clients/client-python/tests/integration/test_semantic_model_authorization.py
b/clients/client-python/tests/integration/test_semantic_model_authorization.py
index e12c7ec13c..258e5f9db6 100644
---
a/clients/client-python/tests/integration/test_semantic_model_authorization.py
+++
b/clients/client-python/tests/integration/test_semantic_model_authorization.py
@@ -22,13 +22,18 @@ import uuid
from unittest.mock import patch
from gravitino import Catalog, GravitinoClient, NameIdentifier
+from gravitino.api.authorization.owner import Owner
from gravitino.api.authorization.privileges import Privileges
from gravitino.api.authorization.securable_objects import SecurableObjects
from gravitino.api.rel.column import Column
from gravitino.api.rel.types.types import Types
from gravitino.api.semantic.dataset import Dataset
+from gravitino.api.semantic.dialect_expression import DialectExpression
+from gravitino.api.semantic.expression import Expression
+from gravitino.api.semantic.metric import Metric
from gravitino.api.semantic.semantic_model_change import SemanticModelChange
from gravitino.api.semantic.semantic_model_definition import
SemanticModelDefinition
+from gravitino.api.semantic.semantic_model_metric import SemanticModelMetric
from gravitino.auth.simple_auth_provider import SimpleAuthProvider
from gravitino.client.metadata_object_role_operations import (
MetadataObjectRoleOperations,
@@ -155,6 +160,76 @@ class
TestSemanticModelAuthorization(AuthorizationIntegrationTestEnv):
[],
self._reader_models.list_semantic_models(Namespace.of("semantic"))
)
+ def test_metric_discovery_follows_model_visibility_and_parent_access(self):
+ metrics = [
+ Metric(name, Expression([DialectExpression("ANSI_SQL",
"COUNT(*)")]))
+ for name in ("Revenue", "revenue")
+ ]
+ definition = SemanticModelDefinition(
+ self._definition.datasets(), metrics=metrics
+ )
+ models = self._gravitino_client.load_catalog(
+ "iceberg"
+ ).as_semantic_model_catalog()
+ for model_name in ("sales", "hidden"):
+ models.alter_semantic_model(
+ NameIdentifier.of("semantic", model_name),
+ SemanticModelChange.replace_definition(definition),
+ )
+ namespace = Namespace.of("semantic")
+ expected = [
+ SemanticModelMetric(Namespace.of("semantic", "sales"), metric)
+ for metric in metrics
+ ]
+
+ def assert_discovery(infos):
+ self.assertEqual(infos,
self._reader_models.list_metric_infos(namespace))
+ self.assertEqual(
+ [
+ NameIdentifier.builder(info.namespace(),
info.metric().name())
+ for info in infos
+ ],
+ self._reader_models.list_metrics(namespace),
+ )
+
+ assert_discovery([])
+ model = SecurableObjects.of_semantic_model(self._schema, "sales", [])
+ for name in ("USE_SEMANTIC_MODEL", "MODIFY_SEMANTIC_MODEL"):
+ with self.subTest(privilege=name):
+ privilege = Privileges.allow(name)
+ self._gravitino_client.grant_privileges_to_role(
+ "reader", model, [privilege]
+ )
+ assert_discovery(expected)
+ self._gravitino_client.revoke_privileges_from_role(
+ "reader", model, [privilege]
+ )
+ assert_discovery([])
+
+ self._gravitino_client.set_owner(model, "semantic_reader",
Owner.Type.USER)
+ assert_discovery(expected)
+ for obj, privilege in (
+ (self._schema, Privileges.allow("USE_SCHEMA")),
+ (
+ SecurableObjects.of_catalog("iceberg", []),
+ Privileges.allow("USE_CATALOG"),
+ ),
+ ):
+ with self.subTest(privilege=privilege.name()):
+ self._gravitino_client.revoke_privileges_from_role(
+ "reader", obj, [privilege]
+ )
+ for method in (
+ self._reader_models.list_metrics,
+ self._reader_models.list_metric_infos,
+ ):
+ with self.assertRaises(ForbiddenException):
+ method(namespace)
+ self._gravitino_client.grant_privileges_to_role(
+ "reader", obj, [privilege]
+ )
+ assert_discovery(expected)
+
def test_create_requires_privilege_and_parent_access(self):
ident = NameIdentifier.of("semantic", "created")
with self.assertRaises(ForbiddenException):
diff --git
a/clients/client-python/tests/integration/test_semantic_model_metric_discovery.py
b/clients/client-python/tests/integration/test_semantic_model_metric_discovery.py
new file mode 100644
index 0000000000..5a41eac8b0
--- /dev/null
+++
b/clients/client-python/tests/integration/test_semantic_model_metric_discovery.py
@@ -0,0 +1,195 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from decimal import Decimal
+from uuid import uuid4
+
+from gravitino import Catalog, GravitinoAdminClient, NameIdentifier
+from gravitino.api.rel.column import Column
+from gravitino.api.rel.types.types import Types
+from gravitino.api.semantic.ai_context import AIContext
+from gravitino.api.semantic.ai_context_object import AIContextObject
+from gravitino.api.semantic.custom_extension import CustomExtension
+from gravitino.api.semantic.data_type import DataType
+from gravitino.api.semantic.dataset import Dataset
+from gravitino.api.semantic.dialect_expression import DialectExpression
+from gravitino.api.semantic.expression import Expression
+from gravitino.api.semantic.metric import Metric
+from gravitino.api.semantic.semantic_model_change import SemanticModelChange
+from gravitino.api.semantic.semantic_model_definition import
SemanticModelDefinition
+from gravitino.api.semantic.semantic_model_metric import SemanticModelMetric
+from gravitino.exceptions.base import NoSuchSchemaException
+from gravitino.namespace import Namespace
+from tests.integration.integration_test_env import IntegrationTestEnv
+
+
+class TestSemanticModelMetricDiscovery(IntegrationTestEnv):
+ """Exercise metric discovery through the public SDK and a real server."""
+
+ def setUp(self):
+ super().setUp()
+ admin = GravitinoAdminClient(uri="http://localhost:8090")
+ self.addCleanup(admin.close)
+ metalake = "metrics_it_" + uuid4().hex
+ client = self.create_metalake_client(admin, metalake)
+ self.addCleanup(client.close)
+ self.addCleanup(admin.drop_metalake, metalake, force=True)
+ self.catalog = client.create_catalog(
+ "metrics_catalog", Catalog.Type.RELATIONAL, "lakehouse-generic",
"", {}
+ )
+ for schema in ("source", "semantic", "other"):
+ self.catalog.as_schemas().create_schema(schema, "", {})
+ self.catalog.as_table_catalog().create_table(
+ NameIdentifier.of("source", "orders"),
+ [Column.of("amount", Types.LongType.get())],
+ properties={
+ "format": "delta",
+ "external": "true",
+ "location": f"file:///tmp/{metalake}/orders",
+ },
+ )
+ self.models = self.catalog.as_semantic_model_catalog()
+ self.namespace = Namespace.of("semantic")
+
+ def
test_discovery_preserves_complete_metrics_order_and_schema_isolation(self):
+ self.assertEqual([], self.models.list_metrics(self.namespace))
+ self.assertEqual([], self.models.list_metric_infos(self.namespace))
+ metrics = [
+ self._metric("revenue"),
+ self._metric("Revenue"),
+ self._metric("收入"),
+ ]
+ self._create("b", [metrics[1]])
+ self._create("a", metrics)
+ self._create("empty", [])
+ self._create("null_metrics", None)
+ self.models.create_semantic_model(
+ NameIdentifier.of("other", "a"), "", self._definition(metrics), {}
+ )
+
+ expected = [
+ SemanticModelMetric(Namespace.of("semantic", "a"), metrics[1]),
+ SemanticModelMetric(Namespace.of("semantic", "a"), metrics[0]),
+ SemanticModelMetric(Namespace.of("semantic", "a"), metrics[2]),
+ SemanticModelMetric(Namespace.of("semantic", "b"), metrics[1]),
+ ]
+ self._assert_discovery(expected)
+ self.assertEqual(3,
len(self.models.list_metrics(Namespace.of("other"))))
+ actual = self.models.list_metric_infos(self.namespace)[0].metric()
+ self.assertIsInstance(
+ actual.ai_context().object().additional_properties()["threshold"],
Decimal
+ )
+
+ def test_discovery_tracks_definition_replacement_rename_and_drop(self):
+ identifier = NameIdentifier.of("semantic", "sales")
+ original = self._metric("Revenue")
+ self._create("sales", [original, self._metric("removed")])
+ changed = Metric(
+ "Revenue",
+ Expression([DialectExpression("ANSI_SQL", "COUNT(*)")]),
+ ai_context=AIContext.of("Updated"),
+ )
+ self.models.alter_semantic_model(
+ identifier,
+
SemanticModelChange.replace_definition(self._definition([changed])),
+ )
+ self._assert_discovery(
+ [SemanticModelMetric(Namespace.of("semantic", "sales"), changed)]
+ )
+
+ self.models.alter_semantic_model(
+ identifier, SemanticModelChange.rename("renamed")
+ )
+ identifier = NameIdentifier.of("semantic", "renamed")
+ self._assert_discovery(
+ [SemanticModelMetric(Namespace.of("semantic", "renamed"), changed)]
+ )
+ self.models.alter_semantic_model(
+ identifier,
SemanticModelChange.replace_definition(self._definition([]))
+ )
+ self._assert_discovery([])
+ self.models.alter_semantic_model(
+ identifier,
+
SemanticModelChange.replace_definition(self._definition([original])),
+ )
+ self._assert_discovery(
+ [SemanticModelMetric(Namespace.of("semantic", "renamed"),
original)]
+ )
+ self.assertTrue(self.models.drop_semantic_model(identifier))
+ self._assert_discovery([])
+
+ def test_missing_and_dropped_schema(self):
+ for method in (self.models.list_metrics,
self.models.list_metric_infos):
+ with self.subTest(method=method),
self.assertRaises(NoSuchSchemaException):
+ method(Namespace.of("missing"))
+ self._create("sales", [self._metric("Revenue")])
+ self.assertTrue(self.catalog.as_schemas().drop_schema("semantic",
True))
+ for method in (self.models.list_metrics,
self.models.list_metric_infos):
+ with self.subTest(method=method),
self.assertRaises(NoSuchSchemaException):
+ method(self.namespace)
+ self.catalog.as_schemas().create_schema("semantic", "", {})
+ self._assert_discovery([])
+
+ def _create(self, name, metrics):
+ self.models.create_semantic_model(
+ NameIdentifier.of("semantic", name), "",
self._definition(metrics), {}
+ )
+
+ def _assert_discovery(self, expected):
+ self.assertEqual(expected,
self.models.list_metric_infos(self.namespace))
+ self.assertEqual(
+ [
+ NameIdentifier.builder(info.namespace(), info.metric().name())
+ for info in expected
+ ],
+ self.models.list_metrics(self.namespace),
+ )
+
+ @staticmethod
+ def _definition(metrics):
+ return SemanticModelDefinition(
+ [
+ Dataset(
+ "orders", NameIdentifier.of("metrics_catalog", "source",
"orders")
+ )
+ ],
+ metrics=metrics,
+ )
+
+ @staticmethod
+ def _metric(name):
+ return Metric(
+ name,
+ Expression([DialectExpression("ANSI_SQL", "SUM(orders.amount)")]),
+ "Revenue - café 雪",
+ DataType.DECIMAL,
+ AIContext.of(
+ AIContextObject(
+ instructions="Use certified metrics",
+ synonyms=["Sales"],
+ examples=["By year"],
+ additional_properties={
+ "threshold":
Decimal("0.12345678901234567890123456789"),
+ "nested": {
+ "large": 1234567890123456789012345678901234567890,
+ "values": [True, None, "extra"],
+ },
+ },
+ )
+ ),
+ [CustomExtension("vendor", '{"opaque": "雪"}')],
+ )
diff --git
a/clients/client-python/tests/unittests/api/semantic/test_semantic_model_catalog.py
b/clients/client-python/tests/unittests/api/semantic/test_semantic_model_catalog.py
index ccf6008e53..75dff8ad96 100644
---
a/clients/client-python/tests/unittests/api/semantic/test_semantic_model_catalog.py
+++
b/clients/client-python/tests/unittests/api/semantic/test_semantic_model_catalog.py
@@ -92,6 +92,17 @@ class _InMemorySemanticModelCatalog(SemanticModelCatalog):
class TestSemanticModelCatalog(unittest.TestCase):
+ def test_metric_discovery_defaults_preserve_existing_implementations(self):
+ catalog = _InMemorySemanticModelCatalog()
+ for method, message in (
+ (catalog.list_metrics, "Metric listing is not supported"),
+ (catalog.list_metric_infos, "Metric info listing is not
supported"),
+ ):
+ with self.subTest(method=method), self.assertRaisesRegex(
+ UnsupportedOperationException, message
+ ):
+ method(Namespace.of("semantic"))
+
def test_semantic_model_exists_uses_load(self):
catalog = _InMemorySemanticModelCatalog()
identifier = NameIdentifier.of("semantic", "sales_model")
diff --git
a/clients/client-python/tests/unittests/test_semantic_model_metric_discovery.py
b/clients/client-python/tests/unittests/test_semantic_model_metric_discovery.py
new file mode 100644
index 0000000000..49531b6fa1
--- /dev/null
+++
b/clients/client-python/tests/unittests/test_semantic_model_metric_discovery.py
@@ -0,0 +1,367 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import io
+import json
+import unittest
+from decimal import Decimal
+from unittest.mock import patch
+from urllib.error import HTTPError
+
+import simplejson
+
+from gravitino.api.semantic.ai_context import AIContext
+from gravitino.api.semantic.ai_context_object import AIContextObject
+from gravitino.api.semantic.custom_extension import CustomExtension
+from gravitino.api.semantic.data_type import DataType
+from gravitino.api.semantic.dialect_expression import DialectExpression
+from gravitino.api.semantic.expression import Expression
+from gravitino.api.semantic.metric import Metric
+from gravitino.api.semantic.semantic_model_metric import SemanticModelMetric
+from gravitino.client.relational_catalog import RelationalCatalog
+from gravitino.dto.audit_dto import AuditDTO
+from gravitino.dto.responses.metric_info_list_response import
MetricInfoListResponse
+from gravitino.exceptions.base import (
+ ForbiddenException,
+ IllegalArgumentException,
+ IllegalNamespaceException,
+ InternalError,
+ NoSuchCatalogException,
+ NoSuchMetalakeException,
+ NoSuchSchemaException,
+ UnauthorizedException,
+ UnsupportedOperationException,
+)
+from gravitino.name_identifier import NameIdentifier
+from gravitino.namespace import Namespace
+from gravitino.utils.http_client import HTTPClient
+
+
+class TestSemanticModelMetricDiscovery(unittest.TestCase):
+ def setUp(self):
+ self.models = RelationalCatalog(
+ catalog_namespace=Namespace.of("metalake /雪"),
+ name="catalog +%",
+ catalog_type=RelationalCatalog.Type.RELATIONAL,
+ provider="lakehouse-generic",
+ audit=AuditDTO("test"),
+ rest_client=HTTPClient("http://localhost:8090"),
+ ).as_semantic_model_catalog()
+ self.namespace = Namespace.of("schema #?")
+ self.full_namespace = ["metalake /雪", "catalog +%", "schema #?",
"a.model"]
+ self.path = (
+ "http://localhost:8090/api/metalakes/metalake%20%2F%E9%9B%AA"
+
"/catalogs/catalog%20%2B%25/schemas/schema%20%23%3F/semantic-model-metrics"
+ )
+ opener_patch = patch("gravitino.utils.http_client.build_opener")
+ self.opener = opener_patch.start().return_value
+ self.addCleanup(opener_patch.stop)
+ self.opener.open.return_value.getcode.return_value = 200
+
+ def
test_list_metrics_uses_one_get_and_preserves_namespaces_and_order(self):
+ identifiers = [
+ {"namespace": self.full_namespace, "name": name}
+ for name in ("Revenue", "revenue", "收入 /é+%")
+ ] + [{"namespace": self.full_namespace[:-1] + ["b"], "name":
"Revenue"}]
+ self._respond({"code": 0, "identifiers": identifiers})
+
+ actual = self.models.list_metrics(self.namespace)
+
+ self._assert_request("false")
+ self.assertEqual(
+ [
+ NameIdentifier.of(*item["namespace"][2:], item["name"])
+ for item in identifiers
+ ],
+ actual,
+ )
+
+ def test_list_metric_infos_preserves_complete_metrics_and_precision(self):
+ metric = self._metric_json()
+ text_metric = {**metric, "name": "revenue", "aiContext": "Use
certified values"}
+ minimal_metric = {"name": "Revenue", "expression":
metric["expression"]}
+ self._respond(
+ {
+ "code": 0,
+ "metrics": [
+ {"namespace": self.full_namespace, "metric": metric},
+ {"namespace": self.full_namespace, "metric": text_metric},
+ {
+ "namespace": self.full_namespace[:-1] + ["b"],
+ "metric": minimal_metric,
+ },
+ ],
+ }
+ )
+
+ actual = self.models.list_metric_infos(self.namespace)
+
+ self._assert_request("true")
+ expression = Expression(
+ [
+ DialectExpression("ANSI_SQL", "SUM(orders.amount)"),
+ DialectExpression("SPARK_SQL", "sum(orders.amount)"),
+ ]
+ )
+ expected = Metric(
+ "Revenue",
+ expression,
+ "Revenue - café 雪",
+ DataType.DECIMAL,
+ AIContext.of(
+ AIContextObject(
+ instructions="Use certified values",
+ synonyms=["Sales"],
+ examples=["Revenue by year"],
+ additional_properties={
+ "nested": {
+ "precise":
Decimal("0.12345678901234567890123456789"),
+ "large": 1234567890123456789012345678901234567890,
+ "values": [True, None, "extra"],
+ }
+ },
+ )
+ ),
+ [CustomExtension("vendor", '{"opaque": "雪"}')],
+ )
+ expected_info = SemanticModelMetric(
+ Namespace.of("schema #?", "a.model"), expected
+ )
+ self.assertEqual(expected_info, actual[0])
+ self.assertEqual(hash(expected_info), hash(actual[0]))
+ self.assertEqual(
+ ["Revenue", "revenue", "Revenue"], [info.metric().name() for info
in actual]
+ )
+ self.assertEqual("Use certified values",
actual[1].metric().ai_context().text())
+ self.assertEqual(Namespace.of("schema #?", "b"), actual[2].namespace())
+ self.assertEqual(Metric("Revenue", expression), actual[2].metric())
+ self.assertIsInstance(
+ actual[0]
+ .metric()
+ .ai_context()
+ .object()
+ .additional_properties()["nested"]["precise"],
+ Decimal,
+ )
+
+ def test_empty_responses(self):
+ for method, field, details in (
+ (self.models.list_metrics, "identifiers", "false"),
+ (self.models.list_metric_infos, "metrics", "true"),
+ ):
+ with self.subTest(details=details):
+ self.opener.open.reset_mock()
+ self._respond({"code": 0, field: []})
+ self.assertEqual([], method(self.namespace))
+ self._assert_request(details)
+
+ def test_invalid_namespaces_do_not_send_requests(self):
+ for method in (self.models.list_metrics,
self.models.list_metric_infos):
+ for namespace in (
+ None,
+ Namespace.empty(),
+ Namespace.of("catalog", "schema"),
+ ):
+ with self.subTest(
+ method=method, namespace=namespace
+ ), self.assertRaises(IllegalNamespaceException):
+ method(namespace)
+ self.opener.open.assert_not_called()
+
+ def test_invalid_response_envelopes(self):
+ for method, field in (
+ (self.models.list_metrics, "identifiers"),
+ (self.models.list_metric_infos, "metrics"),
+ ):
+ for body in (
+ {"code": 0},
+ {"code": 0, field: None},
+ {"code": -1, field: []},
+ ):
+ with self.subTest(method=method, body=body), self.assertRaises(
+ IllegalArgumentException
+ ):
+ self._respond(body)
+ method(self.namespace)
+
+ def test_invalid_full_namespaces_are_rejected(self):
+ for namespace in (
+ [],
+ ["schema", "model"],
+ ["lake", "catalog", "schema"],
+ self.full_namespace + ["extra"],
+ ):
+ with self.subTest(namespace=namespace):
+ expected_message = (
+ "Expected metric namespace with 4 levels
(metalake/catalog/schema/model), "
+ f"got {len(namespace)}"
+ )
+ self._respond(
+ {
+ "code": 0,
+ "identifiers": [{"namespace": namespace, "name":
"Revenue"}],
+ }
+ )
+ with self.assertRaises(IllegalArgumentException) as error:
+ self.models.list_metrics(self.namespace)
+ self.assertEqual(expected_message, str(error.exception))
+ self._respond(
+ {
+ "code": 0,
+ "metrics": [
+ {"namespace": namespace, "metric":
self._metric_json()}
+ ],
+ }
+ )
+ with self.assertRaises(IllegalArgumentException) as error:
+ self.models.list_metric_infos(self.namespace)
+ self.assertEqual(expected_message, str(error.exception))
+
+ def test_missing_identifier_namespace_is_rejected(self):
+ self._respond({"code": 0, "identifiers": [{"name": "Revenue"}]})
+ with self.assertRaises(IllegalArgumentException) as error:
+ self.models.list_metrics(self.namespace)
+ self.assertEqual(
+ "Expected metric namespace with 4 levels
(metalake/catalog/schema/model), got None",
+ str(error.exception),
+ )
+
+ def test_invalid_metric_fails_entire_detailed_request(self):
+ valid = {"namespace": self.full_namespace, "metric":
self._metric_json()}
+ for invalid in (
+ None,
+ {},
+ {"metric": self._metric_json()},
+ {"namespace": self.full_namespace},
+ {"namespace": self.full_namespace, "metric": {"name": "broken"}},
+ {
+ "namespace": self.full_namespace,
+ "metric": {**self._metric_json(), "name": ""},
+ },
+ {
+ "namespace": self.full_namespace,
+ "metric": {**self._metric_json(), "expression": {"dialects":
[]}},
+ },
+ ):
+ with self.subTest(invalid=invalid), self.assertRaises(
+ IllegalArgumentException
+ ):
+ self._respond({"code": 0, "metrics": [valid, invalid]})
+ self.models.list_metric_infos(self.namespace)
+
+ def test_http_error_envelopes_map_to_sdk_exceptions(self):
+ cases = (
+ (404, 1003, NoSuchMetalakeException),
+ (404, 1003, NoSuchCatalogException),
+ (404, 1003, NoSuchSchemaException),
+ (401, 1011, UnauthorizedException),
+ (403, 1008, ForbiddenException),
+ (500, 1002, InternalError),
+ (501, 1006, UnsupportedOperationException),
+ )
+ for method in (self.models.list_metrics,
self.models.list_metric_infos):
+ for status, code, exception in cases:
+ with self.subTest(method=method, exception=exception):
+ body = json.dumps(
+ {
+ "code": code,
+ "type": exception.__name__,
+ "message": "server detail",
+ }
+ ).encode()
+ self.opener.open.side_effect = HTTPError(
+ self.path, status, "error", {}, io.BytesIO(body)
+ )
+ with self.assertRaisesRegex(exception, "server detail"):
+ method(self.namespace)
+
+ def test_metric_info_response_json_round_trip(self):
+ body = {
+ "code": 0,
+ "metrics": [
+ {"namespace": self.full_namespace, "metric":
self._metric_json()}
+ ],
+ }
+ response = MetricInfoListResponse.from_json(simplejson.dumps(body))
+ response.validate()
+ self.assertEqual(body, simplejson.loads(response.to_json(),
use_decimal=True))
+
+ def test_result_value_semantics_and_namespace_copy(self):
+ namespace = Namespace.of("schema", "model")
+ metric = Metric(
+ "Revenue", Expression([DialectExpression("ANSI_SQL", "COUNT(*)")])
+ )
+ result = SemanticModelMetric(namespace, metric)
+ equal = SemanticModelMetric(Namespace.of("schema", "model"), metric)
+ original_hash = hash(result)
+ namespace.levels().clear()
+ result.namespace().levels().clear()
+ self.assertEqual(equal, result)
+ self.assertEqual(original_hash, hash(result))
+ self.assertEqual(hash(equal), hash(result))
+ self.assertNotEqual(result, metric)
+ self.assertNotEqual(
+ result, SemanticModelMetric(Namespace.of("schema", "other"),
metric)
+ )
+ self.assertIn("Revenue", repr(result))
+ self.assertIn("model", repr(result))
+ for namespace, value in (
+ (None, metric),
+ (Namespace.of("schema", "model"), None),
+ ):
+ with self.assertRaises(IllegalArgumentException):
+ SemanticModelMetric(namespace, value)
+
+ def _respond(self, body):
+ self.opener.open.return_value.read.return_value =
simplejson.dumps(body).encode(
+ "utf-8"
+ )
+
+ def _assert_request(self, details):
+ self.opener.open.assert_called_once()
+ request = self.opener.open.call_args.args[0]
+ self.assertEqual(self.path + "?details=" + details, request.full_url)
+ self.assertEqual("GET", request.get_method())
+ self.assertIsNone(request.data)
+ self.assertEqual(
+ "application/vnd.gravitino.v1+json", request.get_header("Accept")
+ )
+
+ @staticmethod
+ def _metric_json():
+ return {
+ "name": "Revenue",
+ "expression": {
+ "dialects": [
+ {"dialect": "ANSI_SQL", "expression":
"SUM(orders.amount)"},
+ {"dialect": "SPARK_SQL", "expression":
"sum(orders.amount)"},
+ ]
+ },
+ "description": "Revenue - café 雪",
+ "datatype": "Decimal",
+ "aiContext": {
+ "instructions": "Use certified values",
+ "synonyms": ["Sales"],
+ "examples": ["Revenue by year"],
+ "nested": {
+ "precise": Decimal("0.12345678901234567890123456789"),
+ "large": 1234567890123456789012345678901234567890,
+ "values": [True, None, "extra"],
+ },
+ },
+ "customExtensions": [{"vendorName": "vendor", "data": '{"opaque":
"雪"}'}],
+ }