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 383c96a7aa [#12614] feat(client-python): Add Semantic Model catalog 
operations (#12860)
383c96a7aa is described below

commit 383c96a7aa829759c63d18a26b4cabf517f5a89d
Author: Akshay Thorat <[email protected]>
AuthorDate: Mon Oct 12 01:13:12 2026 -0700

    [#12614] feat(client-python): Add Semantic Model catalog operations (#12860)
    
    ### What changes were proposed in this pull request?
    
    Add Python Semantic Model list, create, load, alter, exists, and drop
    operations through `catalog.as_semantic_model_catalog()`, with a
    client-side Semantic Model implementation and relational catalog wiring.
    
    Rebased onto current main, which includes #12858 and #12859. The changes
    are prepared as two commits for split review:
    
    1. REST envelopes and error mapping, reviewed separately in #13768.
    2. Catalog operations and their tests, retained in this PR.
    
    Depends on #13768. Until that prerequisite merges, this PR's main-based
    diff includes both commits.
    
    ### Why are the changes needed?
    
    Complete the Python client's Semantic Model support by connecting its
    API to the server REST endpoints.
    
    Fix: #12614
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. Relational catalogs expose working Semantic Model operations
    through `as_semantic_model_catalog()`.
    
    ### How was this patch tested?
    
    The 19 catalog-operation tests cover lifecycle calls, request paths and
    URL encoding, every alter change type, response conversion, and error
    mapping. The prerequisite includes standalone envelope and decimal
    serialization regressions.
    
    Full Python unit suite: 1,212 passed. Black and Pylint pass. Run from
    `clients/client-python`:
    
    ```sh
    python -m pytest tests/unittests -q --tb=short
    ```
    
    A live-server integration run was not repeated for this rebase and
    split.
---
 .../gravitino/client/generic_semantic_model.py     |  75 ++++
 .../gravitino/client/relational_catalog.py         |  19 +
 .../client/semantic_model_catalog_operations.py    | 270 ++++++++++++++
 .../dto/requests/semantic_model_create_request.py  |  69 ++++
 .../dto/requests/semantic_model_update_request.py  | 144 ++++++++
 .../dto/requests/semantic_model_updates_request.py |  43 +++
 .../dto/responses/semantic_model_response.py       |  56 +++
 .../handlers/semantic_model_error_handler.py       |  96 +++++
 .../unittests/dto/test_semantic_model_rest.py      | 137 ++++++++
 .../tests/unittests/test_error_handler.py          | 104 ++++++
 .../tests/unittests/test_semantic_model_catalog.py | 386 +++++++++++++++++++++
 11 files changed, 1399 insertions(+)

diff --git a/clients/client-python/gravitino/client/generic_semantic_model.py 
b/clients/client-python/gravitino/client/generic_semantic_model.py
new file mode 100644
index 0000000000..78cc36a483
--- /dev/null
+++ b/clients/client-python/gravitino/client/generic_semantic_model.py
@@ -0,0 +1,75 @@
+# 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 typing import Optional
+
+from gravitino.api.audit import Audit
+from gravitino.api.semantic.semantic_model import SemanticModel
+from gravitino.api.semantic.semantic_model_definition import 
SemanticModelDefinition
+from gravitino.dto.semantic.semantic_model_dto import SemanticModelDTO
+
+
+class GenericSemanticModel(SemanticModel):
+    """An immutable client-side Semantic Model returned by the Gravitino REST 
API."""
+
+    def __init__(self, semantic_model_dto: SemanticModelDTO):
+        """Create a GenericSemanticModel from a SemanticModelDTO.
+
+        Args:
+            semantic_model_dto (SemanticModelDTO): The Semantic Model DTO.
+        """
+        self._name = semantic_model_dto.name()
+        self._comment = semantic_model_dto.comment()
+        self._definition = semantic_model_dto.definition()
+        self._properties = dict(semantic_model_dto.properties())
+        self._audit = semantic_model_dto.audit_info()
+
+    def name(self) -> str:
+        return self._name
+
+    def comment(self) -> Optional[str]:
+        return self._comment
+
+    def definition(self) -> SemanticModelDefinition:
+        return self._definition
+
+    def properties(self) -> dict[str, str]:
+        return dict(self._properties)
+
+    def audit_info(self) -> Audit:
+        return self._audit
+
+    def __eq__(self, other: object) -> bool:
+        if not isinstance(other, GenericSemanticModel):
+            return False
+        return (
+            self._name == other.name()
+            and self._comment == other.comment()
+            and self._definition == other.definition()
+            and self._properties == other.properties()
+            and self._audit == other.audit_info()
+        )
+
+    def __hash__(self) -> int:
+        return hash((self._name, self._comment, self._definition))
+
+    def __repr__(self) -> str:
+        return (
+            f"GenericSemanticModel(name={self._name!r}, 
comment={self._comment!r}, "
+            f"definition={self._definition!r}, 
properties={self._properties!r}, "
+            f"audit={self._audit!r})"
+        )
diff --git a/clients/client-python/gravitino/client/relational_catalog.py 
b/clients/client-python/gravitino/client/relational_catalog.py
index c4024e26f3..427046d901 100644
--- a/clients/client-python/gravitino/client/relational_catalog.py
+++ b/clients/client-python/gravitino/client/relational_catalog.py
@@ -37,9 +37,13 @@ from gravitino.api.rel.view_change import (
     SetProperty,
     ViewChange,
 )
+from gravitino.api.semantic.semantic_model_catalog import SemanticModelCatalog
 from gravitino.client.base_schema_catalog import BaseSchemaCatalog
 from gravitino.client.generic_view import GenericView
 from gravitino.client.relational_table import RelationalTable
+from gravitino.client.semantic_model_catalog_operations import (
+    SemanticModelCatalogOperations,
+)
 from gravitino.dto.audit_dto import AuditDTO
 from gravitino.dto.rel.distribution_dto import DistributionDTO
 from gravitino.dto.requests.table_create_request import TableCreateRequest
@@ -97,6 +101,9 @@ class RelationalCatalog(
             audit,
             rest_client,
         )
+        self._semantic_model_operations = SemanticModelCatalogOperations(
+            rest_client, catalog_namespace, name
+        )
 
     def support_secrets(self) -> SupportsSecrets:
         return self
@@ -123,6 +130,18 @@ class RelationalCatalog(
         """
         return self
 
+    def as_semantic_model_catalog(self) -> SemanticModelCatalog:
+        """Return this relational catalog as a :class:`SemanticModelCatalog`.
+
+        Semantic Models are always managed by Gravitino, so this support does 
not
+        depend on whether the underlying connector implements a semantic-model
+        capability.
+
+        Returns:
+            SemanticModelCatalog: The Semantic Model operations of this 
catalog.
+        """
+        return self._semantic_model_operations
+
     def _get_entity_full_namespace(self, entity_namespace: Namespace) -> 
Namespace:
         """Get the full namespace of an entity with the given short namespace.
 
diff --git 
a/clients/client-python/gravitino/client/semantic_model_catalog_operations.py 
b/clients/client-python/gravitino/client/semantic_model_catalog_operations.py
new file mode 100644
index 0000000000..ff3569fd61
--- /dev/null
+++ 
b/clients/client-python/gravitino/client/semantic_model_catalog_operations.py
@@ -0,0 +1,270 @@
+# 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 typing import Optional
+
+from gravitino.api.semantic.semantic_model import SemanticModel
+from gravitino.api.semantic.semantic_model_catalog import SemanticModelCatalog
+from gravitino.api.semantic.semantic_model_change import (
+    RemoveProperty,
+    RenameSemanticModel,
+    ReplaceDefinition,
+    SemanticModelChange,
+    SetProperty,
+    UpdateComment,
+)
+from gravitino.api.semantic.semantic_model_definition import 
SemanticModelDefinition
+from gravitino.client.generic_semantic_model import GenericSemanticModel
+from gravitino.dto.requests.semantic_model_create_request import (
+    SemanticModelCreateRequest,
+)
+from gravitino.dto.requests.semantic_model_update_request import (
+    SemanticModelUpdateRequest,
+    SemanticModelUpdateRequestBase,
+)
+from gravitino.dto.requests.semantic_model_updates_request import (
+    SemanticModelUpdatesRequest,
+)
+from gravitino.dto.responses.drop_response import DropResponse
+from gravitino.dto.responses.entity_list_response import EntityListResponse
+from gravitino.dto.responses.semantic_model_response import 
SemanticModelResponse
+from gravitino.dto.semantic.semantic_model_definition_dto import (
+    SemanticModelDefinitionDTO,
+)
+from gravitino.exceptions.base import IllegalArgumentException
+from gravitino.exceptions.handlers.semantic_model_error_handler import (
+    SEMANTIC_MODEL_ERROR_HANDLER,
+)
+from gravitino.name_identifier import NameIdentifier
+from gravitino.namespace import Namespace
+from gravitino.rest.rest_utils import encode_string
+
+
+class SemanticModelCatalogOperations(SemanticModelCatalog):
+    """Implements schema-scoped Semantic Model operations through the 
Gravitino REST API.
+
+    This class is used by catalogs that support Semantic Model operations.
+    """
+
+    def __init__(self, rest_client, catalog_namespace: Namespace, 
catalog_name: str):
+        """Create a SemanticModelCatalogOperations instance.
+
+        Args:
+            rest_client: The REST client for making API calls.
+            catalog_namespace (Namespace): The namespace of the catalog.
+            catalog_name (str): The name of the catalog.
+        """
+        self._rest_client = rest_client
+        self._catalog_namespace = catalog_namespace
+        self._catalog_name = catalog_name
+
+    def list_semantic_models(self, namespace: Namespace) -> 
list[NameIdentifier]:
+        self._check_semantic_model_namespace(namespace)
+        full_namespace = self._get_semantic_model_full_namespace(namespace)
+        resp = self._rest_client.get(
+            self._format_semantic_model_request_path(full_namespace),
+            error_handler=SEMANTIC_MODEL_ERROR_HANDLER,
+        )
+        entity_list_resp = EntityListResponse.from_json(resp.body, 
infer_missing=True)
+        entity_list_resp.validate()
+        return [
+            NameIdentifier.of(ident.namespace().level(2), ident.name())
+            for ident in entity_list_resp.identifiers()
+        ]
+
+    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())
+        resp = self._rest_client.get(
+            f"{self._format_semantic_model_request_path(full_namespace)}"
+            f"/{encode_string(identifier.name())}",
+            error_handler=SEMANTIC_MODEL_ERROR_HANDLER,
+        )
+        return self._to_semantic_model(resp)
+
+    def create_semantic_model(
+        self,
+        identifier: NameIdentifier,
+        comment: Optional[str],
+        definition: SemanticModelDefinition,
+        properties: Optional[dict[str, str]] = None,
+    ) -> SemanticModel:
+        self._check_semantic_model_name_identifier(identifier)
+        if definition is None:
+            raise IllegalArgumentException("Semantic Model definition must not 
be null")
+
+        req = SemanticModelCreateRequest(
+            _name=identifier.name(),
+            _comment=comment,
+            _definition=SemanticModelDefinitionDTO.from_definition(definition),
+            _properties=properties or {},
+        )
+        req.validate()
+
+        full_namespace = 
self._get_semantic_model_full_namespace(identifier.namespace())
+        resp = self._rest_client.post(
+            self._format_semantic_model_request_path(full_namespace),
+            json=req,
+            error_handler=SEMANTIC_MODEL_ERROR_HANDLER,
+        )
+        return self._to_semantic_model(resp)
+
+    def alter_semantic_model(
+        self, identifier: NameIdentifier, *changes: SemanticModelChange
+    ) -> SemanticModel:
+        self._check_semantic_model_name_identifier(identifier)
+        updates_request = SemanticModelUpdatesRequest(
+            _updates=[
+                self._to_semantic_model_update_request(change) for change in 
changes
+            ]
+        )
+        updates_request.validate()
+
+        full_namespace = 
self._get_semantic_model_full_namespace(identifier.namespace())
+        resp = self._rest_client.put(
+            f"{self._format_semantic_model_request_path(full_namespace)}"
+            f"/{encode_string(identifier.name())}",
+            json=updates_request,
+            error_handler=SEMANTIC_MODEL_ERROR_HANDLER,
+        )
+        return self._to_semantic_model(resp)
+
+    def drop_semantic_model(self, identifier: NameIdentifier) -> bool:
+        self._check_semantic_model_name_identifier(identifier)
+        full_namespace = 
self._get_semantic_model_full_namespace(identifier.namespace())
+        resp = self._rest_client.delete(
+            f"{self._format_semantic_model_request_path(full_namespace)}"
+            f"/{encode_string(identifier.name())}",
+            error_handler=SEMANTIC_MODEL_ERROR_HANDLER,
+        )
+        drop_resp = DropResponse.from_json(resp.body, infer_missing=True)
+        drop_resp.validate()
+        return drop_resp.dropped()
+
+    @staticmethod
+    def _to_semantic_model(resp) -> SemanticModel:
+        semantic_model_resp = SemanticModelResponse.from_json(
+            resp.body, infer_missing=True
+        )
+        semantic_model_resp.validate()
+        return GenericSemanticModel(semantic_model_resp.semantic_model())
+
+    @staticmethod
+    def _to_semantic_model_update_request(
+        change: SemanticModelChange,
+    ) -> SemanticModelUpdateRequestBase:
+        if isinstance(change, RenameSemanticModel):
+            return SemanticModelUpdateRequest.RenameSemanticModelRequest(
+                _new_name=change.new_name()
+            )
+
+        if isinstance(change, UpdateComment):
+            return 
SemanticModelUpdateRequest.UpdateSemanticModelCommentRequest(
+                _new_comment=change.new_comment()
+            )
+
+        if isinstance(change, SetProperty):
+            return SemanticModelUpdateRequest.SetSemanticModelPropertyRequest(
+                _property=change.property(), _value=change.value()
+            )
+
+        if isinstance(change, RemoveProperty):
+            return 
SemanticModelUpdateRequest.RemoveSemanticModelPropertyRequest(
+                _property=change.property()
+            )
+
+        if isinstance(change, ReplaceDefinition):
+            return 
SemanticModelUpdateRequest.ReplaceSemanticModelDefinitionRequest(
+                _definition=SemanticModelDefinitionDTO.from_definition(
+                    change.definition()
+                )
+            )
+
+        raise IllegalArgumentException(
+            f"Unknown change type: {change.__class__.__name__}"
+        )
+
+    @staticmethod
+    def _check_semantic_model_namespace(namespace: Namespace) -> None:
+        """Check whether the namespace of a Semantic Model is valid, which 
should be "schema".
+
+        Args:
+            namespace (Namespace): The namespace to check.
+
+        Raises:
+            IllegalNamespaceException: If the Namespace is not valid.
+        """
+        Namespace.check(
+            namespace is not None and namespace.length() == 1,
+            "Semantic Model namespace must be non-null and have 1 level, "
+            f"the input namespace is {namespace}",
+        )
+
+    @staticmethod
+    def _check_semantic_model_name_identifier(identifier: NameIdentifier) -> 
None:
+        """Check whether the `NameIdentifier` of a Semantic Model is valid.
+
+        Args:
+            identifier (NameIdentifier):
+                The NameIdentifier to check, which should be 
"schema.semanticModel" format.
+
+        Raises:
+            IllegalNameIdentifierException: If the NameIdentifier is not valid.
+        """
+        NameIdentifier.check(identifier is not None, "NameIdentifier must not 
be null")
+        NameIdentifier.check(
+            identifier.name() is not None and identifier.name() != "",
+            "NameIdentifier name must not be empty",
+        )
+        SemanticModelCatalogOperations._check_semantic_model_namespace(
+            identifier.namespace()
+        )
+
+    @staticmethod
+    def _format_semantic_model_request_path(namespace: Namespace) -> str:
+        """Format the request path for Semantic Model operations.
+
+        Args:
+            namespace (Namespace): The full namespace 
(metalake.catalog.schema).
+
+        Returns:
+            str: The request path.
+        """
+        return (
+            f"api/metalakes/{encode_string(namespace.level(0))}"
+            f"/catalogs/{encode_string(namespace.level(1))}"
+            f"/schemas/{encode_string(namespace.level(2))}"
+            "/semantic-models"
+        )
+
+    def _get_semantic_model_full_namespace(
+        self, semantic_model_namespace: Namespace
+    ) -> Namespace:
+        """Get the full namespace of a Semantic Model with the given short 
namespace.
+
+        Args:
+            semantic_model_namespace (Namespace):
+                The Semantic Model's short namespace, which is the schema name.
+
+        Returns:
+            Namespace: The full namespace in "metalake.catalog.schema" format.
+        """
+        return Namespace.of(
+            self._catalog_namespace.level(0),
+            self._catalog_name,
+            semantic_model_namespace.level(0),
+        )
diff --git 
a/clients/client-python/gravitino/dto/requests/semantic_model_create_request.py 
b/clients/client-python/gravitino/dto/requests/semantic_model_create_request.py
new file mode 100644
index 0000000000..2e4c51d366
--- /dev/null
+++ 
b/clients/client-python/gravitino/dto/requests/semantic_model_create_request.py
@@ -0,0 +1,69 @@
+# 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.semantic_model_definition import 
SemanticModelDefinition
+from gravitino.dto.semantic.semantic_dto_utils import is_none
+from gravitino.dto.semantic.semantic_model_definition_dto import (
+    SemanticModelDefinitionDTO,
+)
+from gravitino.dto.semantic.json_serdes.semantic_json_mixin import 
SemanticJsonMixin
+from gravitino.rest.rest_message import RESTRequest
+from gravitino.utils.precondition import Precondition
+
+
+@dataclass
+class SemanticModelCreateRequest(SemanticJsonMixin, RESTRequest):
+    """Represents a request to create a Semantic Model."""
+
+    _name: Optional[str] = field(default=None, 
metadata=config(field_name="name"))
+    _comment: Optional[str] = field(
+        default=None,
+        metadata=config(field_name="comment", exclude=is_none),
+    )
+    _definition: Optional[SemanticModelDefinitionDTO] = field(
+        default=None, metadata=config(field_name="definition")
+    )
+    _properties: Optional[dict[str, str]] = field(
+        default=None, metadata=config(field_name="properties")
+    )
+
+    def validate(self) -> None:
+        Precondition.check_string_not_empty(
+            self._name, '"name" field is required and cannot be empty'
+        )
+        Precondition.check_argument(
+            self._definition is not None,
+            '"definition" field is required and cannot be null',
+        )
+        Precondition.check_argument(
+            self._properties is not None,
+            '"properties" field is required and cannot be null',
+        )
+        self.to_definition()
+
+    def to_definition(self) -> SemanticModelDefinition:
+        """Convert the definition in this request to an API definition.
+
+        Raises:
+            IllegalArgumentException: If a definition field is invalid.
+        """
+        return self._definition.to_definition()
diff --git 
a/clients/client-python/gravitino/dto/requests/semantic_model_update_request.py 
b/clients/client-python/gravitino/dto/requests/semantic_model_update_request.py
new file mode 100644
index 0000000000..5c06eac11b
--- /dev/null
+++ 
b/clients/client-python/gravitino/dto/requests/semantic_model_update_request.py
@@ -0,0 +1,144 @@
+# 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 abc import ABC, abstractmethod
+from dataclasses import dataclass, field
+from typing import Optional
+
+from dataclasses_json import config
+
+from gravitino.api.semantic.semantic_model_change import SemanticModelChange
+from gravitino.dto.semantic.semantic_model_definition_dto import (
+    SemanticModelDefinitionDTO,
+)
+from gravitino.dto.semantic.json_serdes.semantic_json_mixin import 
SemanticJsonMixin
+from gravitino.rest.rest_message import RESTRequest
+from gravitino.utils.precondition import Precondition
+
+
+@dataclass
+class SemanticModelUpdateRequestBase(SemanticJsonMixin, RESTRequest, ABC):
+    """Base class for all Semantic Model update requests."""
+
+    _type: str = field(init=False, metadata=config(field_name="@type"))
+
+    @abstractmethod
+    def semantic_model_change(self) -> SemanticModelChange:
+        """Convert to a Semantic Model change operation."""
+
+
+class SemanticModelUpdateRequest:
+    """Namespace for all Semantic Model update request types."""
+
+    @dataclass
+    class RenameSemanticModelRequest(SemanticModelUpdateRequestBase):
+        """Update request to rename a Semantic Model."""
+
+        _new_name: str = field(metadata=config(field_name="newName"))
+
+        def __post_init__(self):
+            self._type = "rename"
+
+        def validate(self):
+            Precondition.check_string_not_empty(
+                self._new_name, '"newName" field is required and cannot be 
empty'
+            )
+
+        def semantic_model_change(self) -> SemanticModelChange:
+            return SemanticModelChange.rename(self._new_name)
+
+    @dataclass
+    class UpdateSemanticModelCommentRequest(SemanticModelUpdateRequestBase):
+        """Update request to update or clear a Semantic Model comment."""
+
+        _new_comment: Optional[str] = field(
+            default=None, metadata=config(field_name="newComment")
+        )
+
+        def __post_init__(self):
+            self._type = "updateComment"
+
+        def validate(self):
+            """Validate the request.
+
+            A null comment clears the current comment, and an empty comment is
+            stored as supplied, so there is nothing to check.
+            """
+
+        def semantic_model_change(self) -> SemanticModelChange:
+            return SemanticModelChange.update_comment(self._new_comment)
+
+    @dataclass
+    class SetSemanticModelPropertyRequest(SemanticModelUpdateRequestBase):
+        """Update request to set a Semantic Model property."""
+
+        _property: str = field(metadata=config(field_name="property"))
+        _value: str = field(metadata=config(field_name="value"))
+
+        def __post_init__(self):
+            self._type = "setProperty"
+
+        def validate(self):
+            Precondition.check_string_not_empty(
+                self._property, '"property" field is required and cannot be 
empty'
+            )
+            Precondition.check_argument(
+                self._value is not None, '"value" field is required and cannot 
be null'
+            )
+
+        def semantic_model_change(self) -> SemanticModelChange:
+            return SemanticModelChange.set_property(self._property, 
self._value)
+
+    @dataclass
+    class RemoveSemanticModelPropertyRequest(SemanticModelUpdateRequestBase):
+        """Update request to remove a Semantic Model property."""
+
+        _property: str = field(metadata=config(field_name="property"))
+
+        def __post_init__(self):
+            self._type = "removeProperty"
+
+        def validate(self):
+            Precondition.check_string_not_empty(
+                self._property, '"property" field is required and cannot be 
empty'
+            )
+
+        def semantic_model_change(self) -> SemanticModelChange:
+            return SemanticModelChange.remove_property(self._property)
+
+    @dataclass
+    class 
ReplaceSemanticModelDefinitionRequest(SemanticModelUpdateRequestBase):
+        """Update request to replace the complete Semantic Model definition."""
+
+        _definition: Optional[SemanticModelDefinitionDTO] = field(
+            default=None, metadata=config(field_name="definition")
+        )
+
+        def __post_init__(self):
+            self._type = "replaceDefinition"
+
+        def validate(self):
+            Precondition.check_argument(
+                self._definition is not None,
+                '"definition" field is required and cannot be null',
+            )
+            self._definition.to_definition()
+
+        def semantic_model_change(self) -> SemanticModelChange:
+            return SemanticModelChange.replace_definition(
+                self._definition.to_definition()
+            )
diff --git 
a/clients/client-python/gravitino/dto/requests/semantic_model_updates_request.py
 
b/clients/client-python/gravitino/dto/requests/semantic_model_updates_request.py
new file mode 100644
index 0000000000..e1a0124b54
--- /dev/null
+++ 
b/clients/client-python/gravitino/dto/requests/semantic_model_updates_request.py
@@ -0,0 +1,43 @@
+# 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.requests.semantic_model_update_request import (
+    SemanticModelUpdateRequestBase,
+)
+from gravitino.dto.semantic.json_serdes.semantic_json_mixin import 
SemanticJsonMixin
+from gravitino.rest.rest_message import RESTRequest
+from gravitino.utils.precondition import Precondition
+
+
+@dataclass
+class SemanticModelUpdatesRequest(SemanticJsonMixin, RESTRequest):
+    """Represents an atomic request to alter a Semantic Model."""
+
+    _updates: list[SemanticModelUpdateRequestBase] = field(
+        metadata=config(field_name="updates")
+    )
+
+    def validate(self):
+        Precondition.check_argument(self._updates is not None, "updates cannot 
be null")
+        Precondition.check_argument(len(self._updates) > 0, "updates cannot be 
empty")
+        for update in self._updates:
+            Precondition.check_argument(update is not None, "update cannot be 
null")
+            update.validate()
diff --git 
a/clients/client-python/gravitino/dto/responses/semantic_model_response.py 
b/clients/client-python/gravitino/dto/responses/semantic_model_response.py
new file mode 100644
index 0000000000..0e46d92608
--- /dev/null
+++ b/clients/client-python/gravitino/dto/responses/semantic_model_response.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 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_dto import SemanticModelDTO
+from gravitino.exceptions.base import IllegalArgumentException
+
+
+@dataclass
+class SemanticModelResponse(SemanticJsonMixin, BaseResponse):
+    """Represents a response containing one Semantic Model."""
+
+    _semantic_model: SemanticModelDTO = field(
+        metadata=config(field_name="semanticModel")
+    )
+
+    def semantic_model(self) -> SemanticModelDTO:
+        """Returns the Semantic Model DTO."""
+        return self._semantic_model
+
+    def validate(self):
+        """Validates the response data.
+
+        Raises:
+            IllegalArgumentException: If the Semantic Model is not set or 
incomplete.
+        """
+        super().validate()
+
+        if self._semantic_model is None:
+            raise IllegalArgumentException("semanticModel must not be null")
+        if not self._semantic_model.name():
+            raise IllegalArgumentException(
+                "semanticModel 'name' must not be null or empty"
+            )
+        self._semantic_model.definition()
+        if self._semantic_model.audit_info() is None:
+            raise IllegalArgumentException("semanticModel 'audit' must not be 
null")
diff --git 
a/clients/client-python/gravitino/exceptions/handlers/semantic_model_error_handler.py
 
b/clients/client-python/gravitino/exceptions/handlers/semantic_model_error_handler.py
new file mode 100644
index 0000000000..cc83cdba80
--- /dev/null
+++ 
b/clients/client-python/gravitino/exceptions/handlers/semantic_model_error_handler.py
@@ -0,0 +1,96 @@
+# 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.constants.error import ErrorConstants
+from gravitino.dto.responses.error_response import ErrorResponse
+from gravitino.exceptions.base import (
+    AlreadyExistsException,
+    CatalogNotInUseException,
+    ForbiddenException,
+    IllegalArgumentException,
+    IllegalSemanticModelException,
+    MetalakeNotInUseException,
+    NoSuchCatalogException,
+    NoSuchMetalakeException,
+    NoSuchSchemaException,
+    NoSuchSemanticModelException,
+    NotFoundException,
+    NotInUseException,
+    SemanticModelAlreadyExistsException,
+    UnsupportedOperationException,
+)
+from gravitino.exceptions.handlers.rest_error_handler import RestErrorHandler
+
+
+class SemanticModelErrorHandler(RestErrorHandler):
+    """Error handler for Semantic Model operations."""
+
+    def handle(self, error_response: ErrorResponse):
+        error_message = error_response.format_error_message()
+        code = error_response.code()
+        exception_type = error_response.type()
+
+        if code == ErrorConstants.ILLEGAL_ARGUMENTS_CODE:
+            if exception_type == IllegalSemanticModelException.__name__:
+                raise IllegalSemanticModelException(error_message)
+
+            raise IllegalArgumentException(error_message)
+
+        if code == ErrorConstants.NOT_FOUND_CODE:
+            self._raise_not_found(exception_type, error_message)
+
+        if code == ErrorConstants.ALREADY_EXISTS_CODE:
+            if exception_type == SemanticModelAlreadyExistsException.__name__:
+                raise SemanticModelAlreadyExistsException(error_message)
+
+            raise AlreadyExistsException(error_message)
+
+        if code == ErrorConstants.UNSUPPORTED_OPERATION_CODE:
+            raise UnsupportedOperationException(error_message)
+
+        if code == ErrorConstants.FORBIDDEN_CODE:
+            raise ForbiddenException(error_message)
+
+        if code == ErrorConstants.NOT_IN_USE_CODE:
+            self._raise_not_in_use(exception_type, error_message)
+
+        super().handle(error_response)
+
+    @staticmethod
+    def _raise_not_found(exception_type: str, error_message: str):
+        if exception_type == NoSuchMetalakeException.__name__:
+            raise NoSuchMetalakeException(error_message)
+        if exception_type == NoSuchCatalogException.__name__:
+            raise NoSuchCatalogException(error_message)
+        if exception_type == NoSuchSchemaException.__name__:
+            raise NoSuchSchemaException(error_message)
+        if exception_type == NoSuchSemanticModelException.__name__:
+            raise NoSuchSemanticModelException(error_message)
+
+        raise NotFoundException(error_message)
+
+    @staticmethod
+    def _raise_not_in_use(exception_type: str, error_message: str):
+        if exception_type == CatalogNotInUseException.__name__:
+            raise CatalogNotInUseException(error_message)
+        if exception_type == MetalakeNotInUseException.__name__:
+            raise MetalakeNotInUseException(error_message)
+
+        raise NotInUseException(error_message)
+
+
+SEMANTIC_MODEL_ERROR_HANDLER = SemanticModelErrorHandler()
diff --git 
a/clients/client-python/tests/unittests/dto/test_semantic_model_rest.py 
b/clients/client-python/tests/unittests/dto/test_semantic_model_rest.py
new file mode 100644
index 0000000000..934ecc8edb
--- /dev/null
+++ b/clients/client-python/tests/unittests/dto/test_semantic_model_rest.py
@@ -0,0 +1,137 @@
+# 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 json
+import unittest
+from decimal import Decimal
+
+from gravitino.api.semantic.ai_context import AIContext
+from gravitino.api.semantic.ai_context_object import AIContextObject
+from gravitino.api.semantic.dataset import Dataset
+from gravitino.api.semantic.semantic_model_definition import 
SemanticModelDefinition
+from gravitino.dto.audit_dto import AuditDTO
+from gravitino.dto.requests.semantic_model_create_request import (
+    SemanticModelCreateRequest,
+)
+from gravitino.dto.requests.semantic_model_update_request import (
+    SemanticModelUpdateRequest,
+)
+from gravitino.dto.requests.semantic_model_updates_request import (
+    SemanticModelUpdatesRequest,
+)
+from gravitino.dto.responses.semantic_model_response import 
SemanticModelResponse
+from gravitino.dto.semantic.semantic_model_definition_dto import (
+    SemanticModelDefinitionDTO,
+)
+from gravitino.dto.semantic.semantic_model_dto import SemanticModelDTO
+from gravitino.name_identifier import NameIdentifier
+
+
+class TestSemanticModelREST(unittest.TestCase):
+    def setUp(self):
+        self.definition = SemanticModelDefinition(
+            datasets=[Dataset("orders", NameIdentifier.of("sales", "mart", 
"orders"))],
+            ai_context=AIContext.of(
+                AIContextObject(
+                    additional_properties={
+                        "nested": [
+                            Decimal("1"),
+                            Decimal("0.123456789012345678901234567890"),
+                        ]
+                    }
+                )
+            ),
+        )
+        self.dto = SemanticModelDefinitionDTO.from_definition(self.definition)
+
+    def test_create_json_preserves_definition(self):
+        request = SemanticModelCreateRequest(
+            _name="sales", _definition=self.dto, _properties={}
+        )
+        request.validate()
+        restored = SemanticModelCreateRequest.from_json(request.to_json())
+        restored.validate()
+        self.assertEqual(self.definition, restored.to_definition())
+        self.assertNotIn("comment", json.loads(request.to_json()))
+
+    def test_replace_json_preserves_definition(self):
+        update = 
SemanticModelUpdateRequest.ReplaceSemanticModelDefinitionRequest(
+            _definition=self.dto
+        )
+        request = SemanticModelUpdatesRequest(_updates=[update])
+        request.validate()
+        payload = json.loads(request.to_json(), parse_float=Decimal)
+        self.assertEqual("replaceDefinition", payload["updates"][0]["@type"])
+        restored = SemanticModelDefinitionDTO.from_dict(
+            payload["updates"][0]["definition"]
+        )
+        self.assertEqual(self.definition, restored.to_definition())
+        self.assertEqual(
+            update.semantic_model_change(),
+            type(update).from_json(update.to_json()).semantic_model_change(),
+        )
+
+    def test_response_json_preserves_definition(self):
+        response = SemanticModelResponse(
+            _code=0,
+            _semantic_model=SemanticModelDTO(
+                _name="sales", _definition=self.dto, _audit=AuditDTO("creator")
+            ),
+        )
+        restored = SemanticModelResponse.from_json(response.to_json())
+        restored.validate()
+        self.assertEqual(self.definition, 
restored.semantic_model().definition())
+
+    def test_update_wire_contract(self):
+        for update, expected in (
+            (
+                SemanticModelUpdateRequest.RenameSemanticModelRequest("new"),
+                {"@type": "rename", "newName": "new"},
+            ),
+            (
+                
SemanticModelUpdateRequest.UpdateSemanticModelCommentRequest(None),
+                {"@type": "updateComment", "newComment": None},
+            ),
+            (
+                SemanticModelUpdateRequest.SetSemanticModelPropertyRequest(
+                    "owner", "team"
+                ),
+                {"@type": "setProperty", "property": "owner", "value": "team"},
+            ),
+            (
+                
SemanticModelUpdateRequest.RemoveSemanticModelPropertyRequest("owner"),
+                {"@type": "removeProperty", "property": "owner"},
+            ),
+        ):
+            with self.subTest(update=type(update)):
+                update.validate()
+                self.assertEqual(expected, json.loads(update.to_json()))
+                self.assertEqual(
+                    update.semantic_model_change(),
+                    
type(update).from_json(update.to_json()).semantic_model_change(),
+                )
+
+    def test_invalid_envelopes(self):
+        for request in (
+            SemanticModelCreateRequest(_name="", _definition=self.dto, 
_properties={}),
+            SemanticModelCreateRequest(_name="sales", _properties={}),
+            SemanticModelUpdatesRequest(_updates=[]),
+            SemanticModelUpdatesRequest(_updates=[None]),
+            SemanticModelResponse(_code=0, _semantic_model=None),
+        ):
+            with self.subTest(request=type(request)), 
self.assertRaises(ValueError):
+                request.validate()
diff --git a/clients/client-python/tests/unittests/test_error_handler.py 
b/clients/client-python/tests/unittests/test_error_handler.py
index 653f0c9708..9ccc2e370a 100644
--- a/clients/client-python/tests/unittests/test_error_handler.py
+++ b/clients/client-python/tests/unittests/test_error_handler.py
@@ -29,6 +29,7 @@ from gravitino.exceptions.base import (
     ForbiddenException,
     IllegalArgumentException,
     IllegalMetadataObjectException,
+    IllegalSemanticModelException,
     IllegalPrivilegeException,
     IllegalRoleException,
     IllegalStatisticNameException,
@@ -45,6 +46,7 @@ from gravitino.exceptions.base import (
     NoSuchRoleException,
     NoSuchGroupException,
     NoSuchSchemaException,
+    NoSuchSemanticModelException,
     NoSuchTableException,
     NoSuchUserException,
     NoSuchViewException,
@@ -56,6 +58,7 @@ from gravitino.exceptions.base import (
     RESTException,
     RoleAlreadyExistsException,
     SchemaAlreadyExistsException,
+    SemanticModelAlreadyExistsException,
     TableAlreadyExistsException,
     UnauthorizedException,
     UnsupportedOperationException,
@@ -77,6 +80,9 @@ from gravitino.exceptions.handlers.rest_error_handler import 
REST_ERROR_HANDLER
 from gravitino.exceptions.handlers.statistics_error_handler import (
     STATISTICS_ERROR_HANDLER,
 )
+from gravitino.exceptions.handlers.semantic_model_error_handler import (
+    SEMANTIC_MODEL_ERROR_HANDLER,
+)
 from gravitino.exceptions.handlers.permission_error_handler import (
     PERMISSION_ERROR_HANDLER,
 )
@@ -909,3 +915,101 @@ class TestErrorHandler(unittest.TestCase):
             PERMISSION_ERROR_HANDLER.handle(
                 ErrorResponse.generate_error_response(NotFoundException, "mock 
error")
             )
+
+    def test_semantic_model_error_handler(self):
+        with self.assertRaises(IllegalSemanticModelException):
+            SEMANTIC_MODEL_ERROR_HANDLER.handle(
+                ErrorResponse.generate_error_response(
+                    IllegalSemanticModelException, "mock error"
+                )
+            )
+
+        with self.assertRaises(IllegalArgumentException):
+            SEMANTIC_MODEL_ERROR_HANDLER.handle(
+                ErrorResponse.generate_error_response(
+                    IllegalArgumentException, "mock error"
+                )
+            )
+
+        with self.assertRaises(NoSuchMetalakeException):
+            SEMANTIC_MODEL_ERROR_HANDLER.handle(
+                ErrorResponse.generate_error_response(
+                    NoSuchMetalakeException, "mock error"
+                )
+            )
+
+        with self.assertRaises(NoSuchCatalogException):
+            SEMANTIC_MODEL_ERROR_HANDLER.handle(
+                ErrorResponse.generate_error_response(
+                    NoSuchCatalogException, "mock error"
+                )
+            )
+
+        with self.assertRaises(NoSuchSchemaException):
+            SEMANTIC_MODEL_ERROR_HANDLER.handle(
+                ErrorResponse.generate_error_response(
+                    NoSuchSchemaException, "mock error"
+                )
+            )
+
+        with self.assertRaises(NoSuchSemanticModelException):
+            SEMANTIC_MODEL_ERROR_HANDLER.handle(
+                ErrorResponse.generate_error_response(
+                    NoSuchSemanticModelException, "mock error"
+                )
+            )
+
+        with self.assertRaises(NotFoundException):
+            SEMANTIC_MODEL_ERROR_HANDLER.handle(
+                ErrorResponse.generate_error_response(NotFoundException, "mock 
error")
+            )
+
+        with self.assertRaises(SemanticModelAlreadyExistsException):
+            SEMANTIC_MODEL_ERROR_HANDLER.handle(
+                ErrorResponse.generate_error_response(
+                    SemanticModelAlreadyExistsException, "mock error"
+                )
+            )
+
+        with self.assertRaises(AlreadyExistsException):
+            SEMANTIC_MODEL_ERROR_HANDLER.handle(
+                ErrorResponse.generate_error_response(
+                    AlreadyExistsException, "mock error"
+                )
+            )
+
+        with self.assertRaises(UnsupportedOperationException):
+            SEMANTIC_MODEL_ERROR_HANDLER.handle(
+                ErrorResponse.generate_error_response(
+                    UnsupportedOperationException, "mock error"
+                )
+            )
+
+        with self.assertRaises(ForbiddenException):
+            SEMANTIC_MODEL_ERROR_HANDLER.handle(
+                ErrorResponse.generate_error_response(ForbiddenException, 
"mock error")
+            )
+
+        with self.assertRaises(CatalogNotInUseException):
+            SEMANTIC_MODEL_ERROR_HANDLER.handle(
+                ErrorResponse.generate_error_response(
+                    CatalogNotInUseException, "mock error"
+                )
+            )
+
+        with self.assertRaises(MetalakeNotInUseException):
+            SEMANTIC_MODEL_ERROR_HANDLER.handle(
+                ErrorResponse.generate_error_response(
+                    MetalakeNotInUseException, "mock error"
+                )
+            )
+
+        with self.assertRaises(NotInUseException):
+            SEMANTIC_MODEL_ERROR_HANDLER.handle(
+                ErrorResponse.generate_error_response(NotInUseException, "mock 
error")
+            )
+
+        with self.assertRaises(RESTException):
+            SEMANTIC_MODEL_ERROR_HANDLER.handle(
+                ErrorResponse.generate_error_response(Exception, "mock error")
+            )
diff --git 
a/clients/client-python/tests/unittests/test_semantic_model_catalog.py 
b/clients/client-python/tests/unittests/test_semantic_model_catalog.py
new file mode 100644
index 0000000000..50030c1b49
--- /dev/null
+++ b/clients/client-python/tests/unittests/test_semantic_model_catalog.py
@@ -0,0 +1,386 @@
+# 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 unittest
+from unittest.mock import Mock, patch
+
+from gravitino.api.semantic.dataset import Dataset
+from gravitino.api.semantic.semantic_model_catalog import SemanticModelCatalog
+from gravitino.api.semantic.semantic_model_change import SemanticModelChange
+from gravitino.api.semantic.semantic_model_definition import 
SemanticModelDefinition
+from gravitino.client.relational_catalog import RelationalCatalog
+from gravitino.dto.audit_dto import AuditDTO
+from gravitino.dto.responses.drop_response import DropResponse
+from gravitino.dto.responses.entity_list_response import EntityListResponse
+from gravitino.dto.responses.semantic_model_response import 
SemanticModelResponse
+from gravitino.dto.semantic.semantic_model_definition_dto import (
+    SemanticModelDefinitionDTO,
+)
+from gravitino.dto.semantic.semantic_model_dto import SemanticModelDTO
+from gravitino.exceptions.base import (
+    IllegalArgumentException,
+    IllegalNameIdentifierException,
+    IllegalNamespaceException,
+    NoSuchSchemaException,
+    NoSuchSemanticModelException,
+    SemanticModelAlreadyExistsException,
+)
+from gravitino.name_identifier import NameIdentifier
+from gravitino.namespace import Namespace
+from gravitino.utils import HTTPClient, Response
+
+
+def _definition() -> SemanticModelDefinition:
+    return SemanticModelDefinition(
+        datasets=[Dataset("orders", NameIdentifier.of("sales", "mart", 
"orders"))]
+    )
+
+
+class TestSemanticModelCatalog(unittest.TestCase):
+    metalake_name = "test_metalake"
+    catalog_name = "test_catalog"
+    schema_name = "test_schema"
+    model_name = "sales_model"
+    catalog_namespace = Namespace.of(metalake_name)
+    request_path = (
+        f"api/metalakes/{metalake_name}/catalogs/{catalog_name}"
+        f"/schemas/{schema_name}/semantic-models"
+    )
+
+    @classmethod
+    def setUpClass(cls) -> None:
+        cls.rest_client = HTTPClient("http://localhost:8090";)
+        cls.catalog = RelationalCatalog(
+            catalog_namespace=cls.catalog_namespace,
+            name=cls.catalog_name,
+            catalog_type=RelationalCatalog.Type.RELATIONAL,
+            provider="test_provider",
+            audit=AuditDTO("anonymous"),
+            rest_client=cls.rest_client,
+        )
+
+    def _get_mock_http_resp(self, json_str: str, return_code: int = 200) -> 
Response:
+        mock_http_resp = Mock()
+        mock_http_resp.getcode.return_value = return_code
+        mock_http_resp.read.return_value = json_str.encode("utf-8")
+        mock_http_resp.info.return_value = None
+        mock_http_resp.url = None
+        return Response(mock_http_resp)
+
+    def _mock_semantic_model_response(self, comment: str = "comment") -> 
Response:
+        semantic_model = SemanticModelDTO(
+            _name=self.model_name,
+            _comment=comment,
+            
_definition=SemanticModelDefinitionDTO.from_definition(_definition()),
+            _properties={"owner": "finance"},
+            _audit=AuditDTO("creator", "2022-01-01T00:00:00Z"),
+        )
+        return self._get_mock_http_resp(
+            SemanticModelResponse(_code=0, 
_semantic_model=semantic_model).to_json()
+        )
+
+    def test_relational_catalog_exposes_semantic_models(self):
+        self.assertIsInstance(
+            self.catalog.as_semantic_model_catalog(), SemanticModelCatalog
+        )
+
+    def test_list_semantic_models(self):
+        first = NameIdentifier.of(
+            self.metalake_name, self.catalog_name, self.schema_name, 
"sales_model"
+        )
+        second = NameIdentifier.of(
+            self.metalake_name, self.catalog_name, self.schema_name, 
"orders_model"
+        )
+        mock_resp = self._get_mock_http_resp(
+            EntityListResponse(_code=0, _idents=[first, second]).to_json()
+        )
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.get", 
return_value=mock_resp
+        ) as mock_get:
+            models = 
self.catalog.as_semantic_model_catalog().list_semantic_models(
+                Namespace.of(self.schema_name)
+            )
+
+        self.assertEqual([self.request_path], [mock_get.call_args.args[0]])
+        self.assertEqual(["sales_model", "orders_model"], [m.name() for m in 
models])
+        self.assertEqual(
+            [self.schema_name, self.schema_name],
+            [m.namespace().level(0) for m in models],
+        )
+
+    def test_list_semantic_models_maps_a_missing_schema(self):
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.get",
+            side_effect=NoSuchSchemaException("schema not found"),
+        ):
+            with self.assertRaises(NoSuchSchemaException):
+                self.catalog.as_semantic_model_catalog().list_semantic_models(
+                    Namespace.of(self.schema_name)
+                )
+
+    def test_load_semantic_model(self):
+        identifier = NameIdentifier.of(self.schema_name, self.model_name)
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.get",
+            return_value=self._mock_semantic_model_response(),
+        ) as mock_get:
+            model = 
self.catalog.as_semantic_model_catalog().load_semantic_model(
+                identifier
+            )
+
+        self.assertEqual(
+            f"{self.request_path}/{self.model_name}", 
mock_get.call_args.args[0]
+        )
+        self.assertEqual(self.model_name, model.name())
+        self.assertEqual("comment", model.comment())
+        self.assertEqual(_definition(), model.definition())
+        self.assertEqual({"owner": "finance"}, model.properties())
+        self.assertEqual("creator", model.audit_info().creator())
+
+    def test_load_semantic_model_maps_a_missing_model(self):
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.get",
+            side_effect=NoSuchSemanticModelException("semantic model not 
found"),
+        ):
+            with self.assertRaises(NoSuchSemanticModelException):
+                self.catalog.as_semantic_model_catalog().load_semantic_model(
+                    NameIdentifier.of(self.schema_name, self.model_name)
+                )
+
+    def test_semantic_model_exists(self):
+        identifier = NameIdentifier.of(self.schema_name, self.model_name)
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.get",
+            return_value=self._mock_semantic_model_response(),
+        ):
+            self.assertTrue(
+                self.catalog.as_semantic_model_catalog().semantic_model_exists(
+                    identifier
+                )
+            )
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.get",
+            side_effect=NoSuchSemanticModelException("semantic model not 
found"),
+        ):
+            self.assertFalse(
+                self.catalog.as_semantic_model_catalog().semantic_model_exists(
+                    identifier
+                )
+            )
+
+    def test_create_semantic_model(self):
+        identifier = NameIdentifier.of(self.schema_name, self.model_name)
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.post",
+            return_value=self._mock_semantic_model_response(),
+        ) as mock_post:
+            model = 
self.catalog.as_semantic_model_catalog().create_semantic_model(
+                identifier, "comment", _definition(), {"owner": "finance"}
+            )
+
+        self.assertEqual(self.request_path, mock_post.call_args.args[0])
+        request = mock_post.call_args.kwargs["json"]
+        self.assertEqual(
+            {
+                "name": self.model_name,
+                "comment": "comment",
+                "definition": {
+                    "datasets": [
+                        {
+                            "name": "orders",
+                            "source": {
+                                "name": "orders",
+                                "namespace": ["sales", "mart"],
+                            },
+                        }
+                    ]
+                },
+                "properties": {"owner": "finance"},
+            },
+            request.to_dict(),
+        )
+        self.assertEqual(self.model_name, model.name())
+
+    def test_create_semantic_model_defaults_properties(self):
+        identifier = NameIdentifier.of(self.schema_name, self.model_name)
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.post",
+            return_value=self._mock_semantic_model_response(),
+        ) as mock_post:
+            self.catalog.as_semantic_model_catalog().create_semantic_model(
+                identifier, None, _definition()
+            )
+
+        request = mock_post.call_args.kwargs["json"]
+        self.assertEqual({}, request.to_dict()["properties"])
+        self.assertNotIn("comment", request.to_dict())
+
+    def test_create_semantic_model_requires_a_definition(self):
+        with self.assertRaisesRegex(
+            IllegalArgumentException, "Semantic Model definition must not be 
null"
+        ):
+            self.catalog.as_semantic_model_catalog().create_semantic_model(
+                NameIdentifier.of(self.schema_name, self.model_name), 
"comment", None
+            )
+
+    def test_create_semantic_model_maps_an_existing_model(self):
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.post",
+            side_effect=SemanticModelAlreadyExistsException("already exists"),
+        ):
+            with self.assertRaises(SemanticModelAlreadyExistsException):
+                self.catalog.as_semantic_model_catalog().create_semantic_model(
+                    NameIdentifier.of(self.schema_name, self.model_name),
+                    "comment",
+                    _definition(),
+                    {},
+                )
+
+    def test_alter_semantic_model_sends_every_change(self):
+        identifier = NameIdentifier.of(self.schema_name, self.model_name)
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.put",
+            return_value=self._mock_semantic_model_response("new comment"),
+        ) as mock_put:
+            model = 
self.catalog.as_semantic_model_catalog().alter_semantic_model(
+                identifier,
+                SemanticModelChange.rename("renamed_model"),
+                SemanticModelChange.update_comment("new comment"),
+                SemanticModelChange.set_property("owner", "finance"),
+                SemanticModelChange.remove_property("legacy"),
+                SemanticModelChange.replace_definition(_definition()),
+            )
+
+        self.assertEqual(
+            f"{self.request_path}/{self.model_name}", 
mock_put.call_args.args[0]
+        )
+        updates = mock_put.call_args.kwargs["json"].to_dict()["updates"]
+        self.assertEqual(
+            [
+                "rename",
+                "updateComment",
+                "setProperty",
+                "removeProperty",
+                "replaceDefinition",
+            ],
+            [update["@type"] for update in updates],
+        )
+        self.assertEqual("renamed_model", updates[0]["newName"])
+        self.assertEqual("new comment", updates[1]["newComment"])
+        self.assertEqual("owner", updates[2]["property"])
+        self.assertEqual("finance", updates[2]["value"])
+        self.assertEqual("legacy", updates[3]["property"])
+        self.assertIn("datasets", updates[4]["definition"])
+        self.assertEqual(self.model_name, model.name())
+
+    def test_alter_semantic_model_can_clear_the_comment(self):
+        identifier = NameIdentifier.of(self.schema_name, self.model_name)
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.put",
+            return_value=self._mock_semantic_model_response(),
+        ) as mock_put:
+            self.catalog.as_semantic_model_catalog().alter_semantic_model(
+                identifier, SemanticModelChange.update_comment(None)
+            )
+
+        updates = mock_put.call_args.kwargs["json"].to_dict()["updates"]
+        self.assertIsNone(updates[0]["newComment"])
+
+    def test_alter_semantic_model_rejects_no_changes(self):
+        with self.assertRaisesRegex(
+            IllegalArgumentException, "updates cannot be empty"
+        ):
+            self.catalog.as_semantic_model_catalog().alter_semantic_model(
+                NameIdentifier.of(self.schema_name, self.model_name)
+            )
+
+    def test_alter_semantic_model_rejects_an_unknown_change(self):
+        with self.assertRaisesRegex(IllegalArgumentException, "Unknown change 
type"):
+            self.catalog.as_semantic_model_catalog().alter_semantic_model(
+                NameIdentifier.of(self.schema_name, self.model_name), object()
+            )
+
+    def test_drop_semantic_model(self):
+        identifier = NameIdentifier.of(self.schema_name, self.model_name)
+        mock_resp = self._get_mock_http_resp(
+            DropResponse(_code=0, _dropped=True).to_json()
+        )
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.delete", 
return_value=mock_resp
+        ) as mock_delete:
+            self.assertTrue(
+                
self.catalog.as_semantic_model_catalog().drop_semantic_model(identifier)
+            )
+
+        self.assertEqual(
+            f"{self.request_path}/{self.model_name}", 
mock_delete.call_args.args[0]
+        )
+
+    def test_drop_missing_semantic_model_returns_false(self):
+        mock_resp = self._get_mock_http_resp(
+            DropResponse(_code=0, _dropped=False).to_json()
+        )
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.delete", 
return_value=mock_resp
+        ):
+            self.assertFalse(
+                self.catalog.as_semantic_model_catalog().drop_semantic_model(
+                    NameIdentifier.of(self.schema_name, self.model_name)
+                )
+            )
+
+    def test_rejects_an_invalid_namespace(self):
+        catalog = self.catalog.as_semantic_model_catalog()
+
+        with self.assertRaises(IllegalNamespaceException):
+            catalog.list_semantic_models(Namespace.of(self.catalog_name, 
"extra"))
+
+        with self.assertRaises(IllegalNamespaceException):
+            catalog.load_semantic_model(
+                NameIdentifier.of(self.catalog_name, self.schema_name, 
self.model_name)
+            )
+
+    def test_rejects_an_invalid_identifier(self):
+        catalog = self.catalog.as_semantic_model_catalog()
+
+        with self.assertRaises(IllegalNameIdentifierException):
+            catalog.load_semantic_model(NameIdentifier.of(self.schema_name, 
""))
+
+    def test_encodes_names_in_the_request_path(self):
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.get",
+            return_value=self._mock_semantic_model_response(),
+        ) as mock_get:
+            self.catalog.as_semantic_model_catalog().load_semantic_model(
+                NameIdentifier.of("schema with space", "model/with/slash")
+            )
+
+        self.assertEqual(
+            f"api/metalakes/{self.metalake_name}/catalogs/{self.catalog_name}"
+            
"/schemas/schema%20with%20space/semantic-models/model%2Fwith%2Fslash",
+            mock_get.call_args.args[0],
+        )

Reply via email to