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 356e8d606d [#12612] feat(client-python): Add Semantic Model API 
definitions (#12858)
356e8d606d is described below

commit 356e8d606d70ea75108f164ebd5e5a8a8ce6e025
Author: Akshay Thorat <[email protected]>
AuthorDate: Sun Sep 27 20:46:54 2026 -0700

    [#12612] feat(client-python): Add Semantic Model API definitions (#12858)
    
    ### What changes were proposed in this pull request?
    
    This PR adds the public Python API for Semantic Models, mirroring the
    Java `org.apache.gravitino.semantic` package added in #12498:
    
    - Ossie-compatible value types: `AIContext`, `AIContextObject`,
    `CustomExtension`, `DataType`, `Dialects`, `DialectExpression`,
    `Expression`, `Dimension`, `Field`, `Dataset`, `Metric`, and
    `Relationship`.
    - `SemanticModelDefinition`, the `SemanticModel` and
    `SemanticModelCatalog` contracts, and `SemanticModelChange` with its
    five supported operations.
    - `NoSuchSemanticModelException`, `SemanticModelAlreadyExistsException`,
    and `IllegalSemanticModelException`.
    - `Catalog.as_semantic_model_catalog()`, which raises
    `UnsupportedOperationException` by default.
    
    Validation mirrors the Java builders, including dialect uniqueness
    within an expression, equal-length and non-empty relationship column
    lists, and the JSON-compatibility, nesting-depth, and cycle checks
    applied to AI-context additional properties. Values are defensively
    copied on construction and on read so instances stay immutable.
    
    `Relationship` exposes `from_dataset()` and `to_dataset()` because
    `from` is a reserved word in Python. The Ossie `from` and `to` names are
    preserved on the wire by the DTO layer.
    
    DTO serialization and REST client operations are out of scope and are
    handled in #12613 and #12614.
    
    ### Why are the changes needed?
    
    The Python client has no Semantic Model support today, so Python users
    cannot manage the Semantic Models introduced by the epic. This is the
    first of three sub-tasks that add that support.
    
    Fix: #12612
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. It adds the public `gravitino.api.semantic` package and
    `Catalog.as_semantic_model_catalog()`. The change is purely additive; no
    existing API changes behavior.
    
    ### How was this patch tested?
    
    Added 51 unit tests across five new test modules covering the supporting
    types, model members, definition, changes, and the catalog contract.
    They assert defaults, equality and hashing, defensive copying, and every
    validation failure path.
    
    ```
    ./gradlew :clients:client-python:test
    ```
    
    ---------
    
    Co-authored-by: mchades <[email protected]>
---
 clients/client-python/gravitino/api/catalog.py     |  15 ++
 .../gravitino/api/semantic/__init__.py             |  16 ++
 .../gravitino/api/semantic/ai_context.py           |  82 +++++++
 .../gravitino/api/semantic/ai_context_object.py    | 227 ++++++++++++++++++
 .../gravitino/api/semantic/custom_extension.py     |  56 +++++
 .../gravitino/api/semantic/data_type.py            |  41 ++++
 .../gravitino/api/semantic/dataset.py              | 167 +++++++++++++
 .../gravitino/api/semantic/dialect_expression.py   |  57 +++++
 .../gravitino/api/semantic/dialects.py             |  38 +++
 .../gravitino/api/semantic/dimension.py            |  46 ++++
 .../gravitino/api/semantic/expression.py           |  61 +++++
 .../client-python/gravitino/api/semantic/field.py  | 138 +++++++++++
 .../client-python/gravitino/api/semantic/metric.py | 122 ++++++++++
 .../gravitino/api/semantic/relationship.py         | 152 ++++++++++++
 .../gravitino/api/semantic/semantic_model.py       |  47 ++++
 .../api/semantic/semantic_model_catalog.py         | 150 ++++++++++++
 .../api/semantic/semantic_model_change.py          | 196 +++++++++++++++
 .../api/semantic/semantic_model_definition.py      | 116 +++++++++
 .../gravitino/api/semantic/semantic_utils.py       |  59 +++++
 clients/client-python/gravitino/exceptions/base.py |  12 +
 .../tests/unittests/api/semantic/__init__.py       |  16 ++
 .../api/semantic/test_semantic_model_catalog.py    | 120 ++++++++++
 .../api/semantic/test_semantic_model_change.py     | 126 ++++++++++
 .../api/semantic/test_semantic_model_definition.py | 143 +++++++++++
 .../api/semantic/test_semantic_model_members.py    | 258 ++++++++++++++++++++
 .../test_semantic_model_supporting_types.py        | 265 +++++++++++++++++++++
 .../test_semantic_model_value_semantics.py         | 220 +++++++++++++++++
 27 files changed, 2946 insertions(+)

diff --git a/clients/client-python/gravitino/api/catalog.py 
b/clients/client-python/gravitino/api/catalog.py
index a3156080ae..7beae70abb 100644
--- a/clients/client-python/gravitino/api/catalog.py
+++ b/clients/client-python/gravitino/api/catalog.py
@@ -170,6 +170,21 @@ class Catalog(Auditable):
         """
         raise UnsupportedOperationException("Catalog does not support view 
operations")
 
+    def as_semantic_model_catalog(self) -> "SemanticModelCatalog":  # noqa: 
F821
+        """
+        Raises:
+            UnsupportedOperationException if the catalog does not support 
Semantic
+            Model operations.
+
+        Returns:
+            the 
:class:`~gravitino.api.semantic.semantic_model_catalog.SemanticModelCatalog`
+            if the catalog supports Semantic Model
+            operations.
+        """
+        raise UnsupportedOperationException(
+            "Catalog does not support semantic model operations"
+        )
+
     def as_fileset_catalog(self) -> "FilesetCatalog":  # noqa: F821
         """
         Raises:
diff --git a/clients/client-python/gravitino/api/semantic/__init__.py 
b/clients/client-python/gravitino/api/semantic/__init__.py
new file mode 100644
index 0000000000..13a83393a9
--- /dev/null
+++ b/clients/client-python/gravitino/api/semantic/__init__.py
@@ -0,0 +1,16 @@
+# 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.
diff --git a/clients/client-python/gravitino/api/semantic/ai_context.py 
b/clients/client-python/gravitino/api/semantic/ai_context.py
new file mode 100644
index 0000000000..ce1e600db6
--- /dev/null
+++ b/clients/client-python/gravitino/api/semantic/ai_context.py
@@ -0,0 +1,82 @@
+# 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, Union
+
+from gravitino.api.semantic.ai_context_object import AIContextObject
+from gravitino.exceptions.base import IllegalArgumentException
+from gravitino.utils.precondition import Precondition
+
+
+class AIContext:
+    """An immutable wrapper holding exactly one of a free-form string or an
+    :class:`AIContextObject`.
+
+    Instances are created through :meth:`of`.
+    """
+
+    def __init__(self, text: Optional[str], obj: Optional[AIContextObject]):
+        Precondition.check_argument(
+            (text is None) != (obj is None),
+            "AI context must contain exactly one of text or object",
+        )
+        self._text = text
+        self._object = obj
+
+    @staticmethod
+    def of(value: Union[str, AIContextObject]) -> "AIContext":
+        """Create an AI context from a string or a structured object.
+
+        Args:
+            value (str | AIContextObject): The AI context value.
+
+        Returns:
+            AIContext: The AI context holding the given value.
+
+        Raises:
+            IllegalArgumentException: If the value is `None` or an unsupported 
type.
+        """
+        if isinstance(value, str):
+            return AIContext(value, None)
+        if isinstance(value, AIContextObject):
+            return AIContext(None, value)
+        raise IllegalArgumentException(
+            "AI context must be a string or an AIContextObject"
+        )
+
+    def is_text(self) -> bool:
+        """Returns `True` if this AI context holds a string."""
+        return self._text is not None
+
+    def text(self) -> Optional[str]:
+        """Returns the string value, or `None` if this holds a structured 
object."""
+        return self._text
+
+    def object(self) -> Optional[AIContextObject]:
+        """Returns the structured value, or `None` if this holds a string."""
+        return self._object
+
+    def __eq__(self, other: object) -> bool:
+        if not isinstance(other, AIContext):
+            return False
+        return self._text == other.text() and self._object == other.object()
+
+    def __hash__(self) -> int:
+        return hash((self._text, self._object))
+
+    def __repr__(self) -> str:
+        return f"AIContext(text={self._text!r}, object={self._object!r})"
diff --git a/clients/client-python/gravitino/api/semantic/ai_context_object.py 
b/clients/client-python/gravitino/api/semantic/ai_context_object.py
new file mode 100644
index 0000000000..bee224a78a
--- /dev/null
+++ b/clients/client-python/gravitino/api/semantic/ai_context_object.py
@@ -0,0 +1,227 @@
+# 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 copy
+import math
+from decimal import Decimal
+from typing import Any, Final, Optional
+
+from gravitino.exceptions.base import IllegalArgumentException
+from gravitino.utils.precondition import Precondition
+
+MAX_ADDITIONAL_PROPERTY_NESTING_DEPTH: Final[int] = 100
+
+_STANDARD_PROPERTIES: Final[frozenset] = frozenset(
+    {"instructions", "synonyms", "examples"}
+)
+
+
+class AIContextObject:
+    """The structured form of AI context attached to a Semantic Model member.
+
+    Unknown JSON-compatible properties are exposed through
+    :meth:`additional_properties` and are retained losslessly. Finite
+    :class:`~decimal.Decimal` values retain their precision without conversion
+    to floating point. Equality and hashing treat floats and equivalent 
Decimals
+    as the same decimal value, distinct from booleans and integers.
+    """
+
+    def __init__(
+        self,
+        instructions: Optional[str] = None,
+        synonyms: Optional[list[str]] = None,
+        examples: Optional[list[str]] = None,
+        additional_properties: Optional[dict[str, Any]] = None,
+    ):
+        Precondition.check_argument(
+            instructions is None or isinstance(instructions, str),
+            "instructions must be a string or null",
+        )
+        _check_string_elements("synonyms", synonyms)
+        _check_string_elements("examples", examples)
+
+        self._instructions = instructions
+        self._synonyms = None if synonyms is None else list(synonyms)
+        self._examples = None if examples is None else list(examples)
+        self._additional_properties = _normalize_additional_properties(
+            additional_properties
+        )
+
+    def instructions(self) -> Optional[str]:
+        """Returns the free-form instructions, or `None` if it is not set."""
+        return self._instructions
+
+    def synonyms(self) -> Optional[list[str]]:
+        """Returns the synonyms, or `None` if they are not set."""
+        return None if self._synonyms is None else list(self._synonyms)
+
+    def examples(self) -> Optional[list[str]]:
+        """Returns the examples, or `None` if they are not set."""
+        return None if self._examples is None else list(self._examples)
+
+    def additional_properties(self) -> dict[str, Any]:
+        """Returns the additional JSON-compatible properties, empty if none 
are set."""
+        return copy.deepcopy(self._additional_properties)
+
+    def __eq__(self, other: object) -> bool:
+        if not isinstance(other, AIContextObject):
+            return False
+        return (
+            self._instructions == other.instructions()
+            and self._synonyms == other.synonyms()
+            and self._examples == other.examples()
+            and _freeze_json_value(self._additional_properties)
+            == _freeze_json_value(other.additional_properties())
+        )
+
+    def __hash__(self) -> int:
+        return hash(
+            (
+                self._instructions,
+                None if self._synonyms is None else tuple(self._synonyms),
+                None if self._examples is None else tuple(self._examples),
+                _freeze_json_value(self._additional_properties),
+            )
+        )
+
+    def __repr__(self) -> str:
+        return (
+            f"AIContextObject(instructions={self._instructions!r}, "
+            f"synonyms={self._synonyms!r}, examples={self._examples!r}, "
+            f"additionalProperties={self._additional_properties!r})"
+        )
+
+
+def _normalize_additional_properties(
+    properties: Optional[dict[str, Any]],
+) -> dict[str, Any]:
+    if properties is None:
+        return {}
+
+    normalized = {}
+    for name, value in properties.items():
+        Precondition.check_argument(
+            isinstance(name, str), "additional property name must be a string"
+        )
+        Precondition.check_argument(
+            name not in _STANDARD_PROPERTIES,
+            f"additional property must not duplicate standard property: 
{name}",
+        )
+        normalized[name] = _normalize_json_value(value, name, set(), 0)
+    return normalized
+
+
+def _normalize_json_value(
+    value: Any, path: str, visiting: set[int], container_depth: int
+) -> Any:
+    if value is None or isinstance(value, (str, bool)):
+        return value
+
+    if isinstance(value, Decimal):
+        Precondition.check_argument(
+            value.is_finite(),
+            f"Additional property {path} must contain a finite number",
+        )
+        return value
+
+    if isinstance(value, (int, float)):
+        Precondition.check_argument(
+            not isinstance(value, float) or math.isfinite(value),
+            f"Additional property {path} must contain a finite number",
+        )
+        return value
+
+    if isinstance(value, dict):
+        return _normalize_json_dict(value, path, visiting, container_depth + 1)
+
+    if isinstance(value, (list, tuple)):
+        return _normalize_json_list(value, path, visiting, container_depth + 1)
+
+    raise IllegalArgumentException(
+        f"Additional property {path} has non-JSON-compatible value type: "
+        f"{type(value).__name__}"
+    )
+
+
+def _normalize_json_dict(
+    value: dict, path: str, visiting: set[int], container_depth: int
+) -> dict[str, Any]:
+    _enter_container(value, path, visiting, container_depth)
+    try:
+        normalized = {}
+        for key, item in value.items():
+            Precondition.check_argument(
+                isinstance(key, str),
+                f"Additional property {path} contains a map key that is not a 
string",
+            )
+            normalized[key] = _normalize_json_value(
+                item, f"{path}.{key}", visiting, container_depth
+            )
+        return normalized
+    finally:
+        visiting.discard(id(value))
+
+
+def _normalize_json_list(
+    value, path: str, visiting: set[int], container_depth: int
+) -> list[Any]:
+    _enter_container(value, path, visiting, container_depth)
+    try:
+        return [
+            _normalize_json_value(item, f"{path}[{index}]", visiting, 
container_depth)
+            for index, item in enumerate(value)
+        ]
+    finally:
+        visiting.discard(id(value))
+
+
+def _enter_container(
+    value: Any, path: str, visiting: set[int], container_depth: int
+) -> None:
+    Precondition.check_argument(
+        container_depth <= MAX_ADDITIONAL_PROPERTY_NESTING_DEPTH,
+        f"Additional property {path} exceeds maximum nesting depth of "
+        f"{MAX_ADDITIONAL_PROPERTY_NESTING_DEPTH}",
+    )
+    Precondition.check_argument(
+        id(value) not in visiting,
+        f"Additional property {path} contains a cyclic value",
+    )
+    visiting.add(id(value))
+
+
+def _check_string_elements(name: str, values: Optional[list[str]]) -> None:
+    if values is not None:
+        for index, value in enumerate(values):
+            Precondition.check_argument(
+                isinstance(value, str), f"{name}[{index}] must be a string"
+            )
+
+
+def _freeze_json_value(value: Any) -> tuple:
+    """Return an order-independent, type-aware key for normalized JSON 
values."""
+    if isinstance(value, dict):
+        return (
+            dict,
+            frozenset((key, _freeze_json_value(item)) for key, item in 
value.items()),
+        )
+    if isinstance(value, list):
+        return (list, tuple(_freeze_json_value(item) for item in value))
+    if isinstance(value, float):
+        # Use the JSON decimal representation, not the binary floating-point 
value.
+        return (Decimal, Decimal(str(value)))
+    return (type(value), value)
diff --git a/clients/client-python/gravitino/api/semantic/custom_extension.py 
b/clients/client-python/gravitino/api/semantic/custom_extension.py
new file mode 100644
index 0000000000..603b6f37a2
--- /dev/null
+++ b/clients/client-python/gravitino/api/semantic/custom_extension.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.utils.precondition import Precondition
+
+
+class CustomExtension:
+    """A vendor-specific extension carried by a Semantic Model member.
+
+    Custom extensions are preserved losslessly for Ossie interchange, Gravitino
+    does not interpret the extension data.
+    """
+
+    def __init__(self, vendor_name: str, data: str):
+        Precondition.check_argument(
+            isinstance(vendor_name, str),
+            "vendorName must not be null and must be a string",
+        )
+        Precondition.check_argument(
+            isinstance(data, str), "data must not be null and must be a string"
+        )
+        self._vendor_name = vendor_name
+        self._data = data
+
+    def vendor_name(self) -> str:
+        """Returns the vendor name that owns this extension."""
+        return self._vendor_name
+
+    def data(self) -> str:
+        """Returns the opaque extension data."""
+        return self._data
+
+    def __eq__(self, other: object) -> bool:
+        if not isinstance(other, CustomExtension):
+            return False
+        return self._vendor_name == other.vendor_name() and self._data == 
other.data()
+
+    def __hash__(self) -> int:
+        return hash((self._vendor_name, self._data))
+
+    def __repr__(self) -> str:
+        return f"CustomExtension(vendorName={self._vendor_name!r}, 
data={self._data!r})"
diff --git a/clients/client-python/gravitino/api/semantic/data_type.py 
b/clients/client-python/gravitino/api/semantic/data_type.py
new file mode 100644
index 0000000000..5942a674a4
--- /dev/null
+++ b/clients/client-python/gravitino/api/semantic/data_type.py
@@ -0,0 +1,41 @@
+# 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 enum import Enum
+
+
+class DataType(Enum):
+    """The logical type vocabulary used by Semantic Model fields and metrics.
+
+    This vocabulary is derived from the Apache Ossie Core specification and is
+    deliberately independent of the Gravitino relational type system. Gravitino
+    does not infer or convert these values from source column types.
+
+    The enum value is the exact Ossie wire value, for example 
``DataType.DECIMAL``
+    is serialized as ``"Decimal"``.
+    """
+
+    STRING = "String"
+    INTEGER = "Integer"
+    DECIMAL = "Decimal"
+    FLOAT = "Float"
+    BOOLEAN = "Boolean"
+    DATE = "Date"
+    TIME = "Time"
+    DATE_TIME = "DateTime"
+    DATE_TIME_TZ = "DateTimeTz"
+    OPAQUE = "Opaque"
diff --git a/clients/client-python/gravitino/api/semantic/dataset.py 
b/clients/client-python/gravitino/api/semantic/dataset.py
new file mode 100644
index 0000000000..7259ef852a
--- /dev/null
+++ b/clients/client-python/gravitino/api/semantic/dataset.py
@@ -0,0 +1,167 @@
+# 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.ai_context import AIContext
+from gravitino.api.semantic.custom_extension import CustomExtension
+from gravitino.api.semantic.field import Field
+from gravitino.api.semantic.semantic_utils import (
+    check_no_none_elements,
+    check_non_empty_string_elements,
+)
+from gravitino.name_identifier import NameIdentifier
+from gravitino.utils.precondition import Precondition
+
+
+class Dataset:  # pylint: disable=too-many-instance-attributes
+    """A source-backed dataset exposed by a Semantic Model.
+
+    Dataset names are unique within a Semantic Model. The source is a 
three-part
+    `NameIdentifier` that must resolve to a table or a logical view in the same
+    metalake, inline query sources are not supported.
+    """
+
+    def __init__(
+        self,
+        name: str,
+        source: NameIdentifier,
+        primary_key: Optional[list[str]] = None,
+        unique_keys: Optional[list[list[str]]] = None,
+        description: Optional[str] = None,
+        ai_context: Optional[AIContext] = None,
+        fields: Optional[list[Field]] = None,
+        custom_extensions: Optional[list[CustomExtension]] = None,
+    ):
+        Precondition.check_argument(
+            name is not None and name != "", "name must not be null or empty"
+        )
+        Precondition.check_argument(source is not None, "source must not be 
null")
+        check_non_empty_string_elements("primaryKey", primary_key)
+        _check_unique_keys(unique_keys)
+        check_no_none_elements("fields", fields)
+        check_no_none_elements("customExtensions", custom_extensions)
+
+        self._name = name
+        self._source = NameIdentifier.of(*source.namespace().levels(), 
source.name())
+        self._primary_key = None if primary_key is None else list(primary_key)
+        self._unique_keys = _copy_unique_keys(unique_keys)
+        self._description = description
+        self._ai_context = ai_context
+        self._fields = None if fields is None else list(fields)
+        self._custom_extensions = (
+            None if custom_extensions is None else list(custom_extensions)
+        )
+
+    def name(self) -> str:
+        """Returns the dataset name."""
+        return self._name
+
+    def source(self) -> NameIdentifier:
+        """Returns the identifier of the table or view backing the dataset."""
+        return NameIdentifier.of(
+            *self._source.namespace().levels(), self._source.name()
+        )
+
+    def primary_key(self) -> Optional[list[str]]:
+        """Returns the primary key columns, or `None` if they are not set."""
+        return None if self._primary_key is None else list(self._primary_key)
+
+    def unique_keys(self) -> Optional[list[list[str]]]:
+        """Returns the unique key column groups, or `None` if they are not 
set."""
+        return _copy_unique_keys(self._unique_keys)
+
+    def description(self) -> Optional[str]:
+        """Returns the dataset description, or `None` if it is not set."""
+        return self._description
+
+    def ai_context(self) -> Optional[AIContext]:
+        """Returns the AI context, or `None` if it is not set."""
+        return self._ai_context
+
+    def fields(self) -> Optional[list[Field]]:
+        """Returns the dataset fields, or `None` if they are not set."""
+        return None if self._fields is None else list(self._fields)
+
+    def custom_extensions(self) -> Optional[list[CustomExtension]]:
+        """Returns the custom extensions, or `None` if they are not set."""
+        return (
+            None if self._custom_extensions is None else 
list(self._custom_extensions)
+        )
+
+    def __eq__(self, other: object) -> bool:
+        if not isinstance(other, Dataset):
+            return False
+        return (
+            self._name == other.name()
+            and self._source == other.source()
+            and self._primary_key == other.primary_key()
+            and self._unique_keys == other.unique_keys()
+            and self._description == other.description()
+            and self._ai_context == other.ai_context()
+            and self._fields == other.fields()
+            and self._custom_extensions == other.custom_extensions()
+        )
+
+    def __hash__(self) -> int:
+        return hash(
+            (
+                self._name,
+                self._source,
+                None if self._primary_key is None else 
tuple(self._primary_key),
+                (
+                    None
+                    if self._unique_keys is None
+                    else tuple(tuple(key) for key in self._unique_keys)
+                ),
+                self._description,
+                self._ai_context,
+                None if self._fields is None else tuple(self._fields),
+                (
+                    None
+                    if self._custom_extensions is None
+                    else tuple(self._custom_extensions)
+                ),
+            )
+        )
+
+    def __repr__(self) -> str:
+        return (
+            f"Dataset(name={self._name!r}, source={self._source!r}, "
+            f"primaryKey={self._primary_key!r}, 
uniqueKeys={self._unique_keys!r}, "
+            f"description={self._description!r}, 
aiContext={self._ai_context!r}, "
+            f"fields={self._fields!r}, 
customExtensions={self._custom_extensions!r})"
+        )
+
+
+def _check_unique_keys(unique_keys: Optional[list[list[str]]]) -> None:
+    if unique_keys is None:
+        return
+    for index, unique_key in enumerate(unique_keys):
+        Precondition.check_argument(
+            unique_key is not None and len(unique_key) > 0,
+            f"uniqueKeys[{index}] must not be null or empty",
+        )
+        check_non_empty_string_elements(f"uniqueKeys[{index}]", unique_key)
+
+
+def _copy_unique_keys(
+    unique_keys: Optional[list[list[str]]],
+) -> Optional[list[list[str]]]:
+    if unique_keys is None:
+        return None
+    return [list(unique_key) for unique_key in unique_keys]
diff --git a/clients/client-python/gravitino/api/semantic/dialect_expression.py 
b/clients/client-python/gravitino/api/semantic/dialect_expression.py
new file mode 100644
index 0000000000..75cbfbbcfa
--- /dev/null
+++ b/clients/client-python/gravitino/api/semantic/dialect_expression.py
@@ -0,0 +1,57 @@
+# 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.utils.precondition import Precondition
+
+
+class DialectExpression:
+    """One dialect-specific rendering of a Semantic Model expression."""
+
+    def __init__(self, dialect: str, expression: str):
+        Precondition.check_argument(
+            dialect is not None and dialect != "", "dialect must not be null 
or empty"
+        )
+        Precondition.check_argument(
+            expression is not None and expression != "",
+            "expression must not be null or empty",
+        )
+        self._dialect = dialect
+        self._expression = expression
+
+    def dialect(self) -> str:
+        """Returns the dialect identifier, see :class:`Dialects`."""
+        return self._dialect
+
+    def expression(self) -> str:
+        """Returns the expression rendered in this dialect."""
+        return self._expression
+
+    def __eq__(self, other: object) -> bool:
+        if not isinstance(other, DialectExpression):
+            return False
+        return (
+            self._dialect == other.dialect() and self._expression == 
other.expression()
+        )
+
+    def __hash__(self) -> int:
+        return hash((self._dialect, self._expression))
+
+    def __repr__(self) -> str:
+        return (
+            f"DialectExpression(dialect={self._dialect!r}, "
+            f"expression={self._expression!r})"
+        )
diff --git a/clients/client-python/gravitino/api/semantic/dialects.py 
b/clients/client-python/gravitino/api/semantic/dialects.py
new file mode 100644
index 0000000000..3dda314e00
--- /dev/null
+++ b/clients/client-python/gravitino/api/semantic/dialects.py
@@ -0,0 +1,38 @@
+# 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 Final
+
+
+class Dialects:
+    """Well-known dialect identifiers for Semantic Model expressions.
+
+    Other identifiers are accepted and preserved without normalization,
+    translation, or fallback. Dialect identifiers are compared exactly and
+    case-sensitively, so ``trino`` and ``TRINO`` are distinct.
+    """
+
+    ANSI_SQL: Final[str] = "ANSI_SQL"
+    SNOWFLAKE: Final[str] = "SNOWFLAKE"
+    MDX: Final[str] = "MDX"
+    TABLEAU: Final[str] = "TABLEAU"
+    DATABRICKS: Final[str] = "DATABRICKS"
+    MAQL: Final[str] = "MAQL"
+    BIGQUERY: Final[str] = "BIGQUERY"
+
+    def __init__(self):
+        raise TypeError("Dialects is a constant holder and must not be 
instantiated")
diff --git a/clients/client-python/gravitino/api/semantic/dimension.py 
b/clients/client-python/gravitino/api/semantic/dimension.py
new file mode 100644
index 0000000000..b81b64aeb7
--- /dev/null
+++ b/clients/client-python/gravitino/api/semantic/dimension.py
@@ -0,0 +1,46 @@
+# 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.utils.precondition import Precondition
+
+
+class Dimension:
+    """Marks a Semantic Model field as a dimension."""
+
+    def __init__(self, is_time: Optional[bool] = None):
+        Precondition.check_argument(
+            is_time is None or isinstance(is_time, bool),
+            "isTime must be a boolean or null",
+        )
+        self._is_time = is_time
+
+    def is_time(self) -> Optional[bool]:
+        """Returns whether the dimension is a time dimension, `None` if not 
set."""
+        return self._is_time
+
+    def __eq__(self, other: object) -> bool:
+        if not isinstance(other, Dimension):
+            return False
+        return self._is_time == other.is_time()
+
+    def __hash__(self) -> int:
+        return hash(self._is_time)
+
+    def __repr__(self) -> str:
+        return f"Dimension(isTime={self._is_time!r})"
diff --git a/clients/client-python/gravitino/api/semantic/expression.py 
b/clients/client-python/gravitino/api/semantic/expression.py
new file mode 100644
index 0000000000..4ec421befc
--- /dev/null
+++ b/clients/client-python/gravitino/api/semantic/expression.py
@@ -0,0 +1,61 @@
+# 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.dialect_expression import DialectExpression
+from gravitino.api.semantic.semantic_utils import check_no_none_elements
+from gravitino.utils.precondition import Precondition
+
+
+class Expression:
+    """A Semantic Model expression rendered in one or more dialects.
+
+    Every expression declares at least one dialect and each dialect identifier
+    appears at most once.
+    """
+
+    def __init__(self, dialects: list[DialectExpression]):
+        Precondition.check_argument(
+            dialects is not None and len(dialects) > 0,
+            "dialects must not be null or empty",
+        )
+        check_no_none_elements("dialects", dialects)
+
+        seen_dialects = set()
+        for dialect_expression in dialects:
+            Precondition.check_argument(
+                dialect_expression.dialect() not in seen_dialects,
+                "dialects must not contain duplicate dialect: "
+                f"{dialect_expression.dialect()}",
+            )
+            seen_dialects.add(dialect_expression.dialect())
+
+        self._dialects = list(dialects)
+
+    def dialects(self) -> list[DialectExpression]:
+        """Returns the dialect-specific renderings of this expression."""
+        return list(self._dialects)
+
+    def __eq__(self, other: object) -> bool:
+        if not isinstance(other, Expression):
+            return False
+        return self._dialects == other.dialects()
+
+    def __hash__(self) -> int:
+        return hash(tuple(self._dialects))
+
+    def __repr__(self) -> str:
+        return f"Expression(dialects={self._dialects!r})"
diff --git a/clients/client-python/gravitino/api/semantic/field.py 
b/clients/client-python/gravitino/api/semantic/field.py
new file mode 100644
index 0000000000..656fee5144
--- /dev/null
+++ b/clients/client-python/gravitino/api/semantic/field.py
@@ -0,0 +1,138 @@
+# 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.ai_context import AIContext
+from gravitino.api.semantic.custom_extension import CustomExtension
+from gravitino.api.semantic.data_type import DataType
+from gravitino.api.semantic.dimension import Dimension
+from gravitino.api.semantic.expression import Expression
+from gravitino.api.semantic.semantic_utils import check_no_none_elements
+from gravitino.utils.precondition import Precondition
+
+
+class Field:  # pylint: disable=too-many-instance-attributes
+    """A named expression exposed by a Semantic Model dataset.
+
+    Field names are unique within their dataset.
+    """
+
+    def __init__(
+        self,
+        name: str,
+        expression: Expression,
+        dimension: Optional[Dimension] = None,
+        label: Optional[str] = None,
+        description: Optional[str] = None,
+        datatype: Optional[DataType] = None,
+        ai_context: Optional[AIContext] = None,
+        custom_extensions: Optional[list[CustomExtension]] = None,
+    ):
+        Precondition.check_argument(
+            name is not None and name != "", "name must not be null or empty"
+        )
+        Precondition.check_argument(
+            expression is not None, "expression must not be null"
+        )
+        check_no_none_elements("customExtensions", custom_extensions)
+
+        self._name = name
+        self._expression = expression
+        self._dimension = dimension
+        self._label = label
+        self._description = description
+        self._datatype = datatype
+        self._ai_context = ai_context
+        self._custom_extensions = (
+            None if custom_extensions is None else list(custom_extensions)
+        )
+
+    def name(self) -> str:
+        """Returns the field name."""
+        return self._name
+
+    def expression(self) -> Expression:
+        """Returns the expression that produces the field."""
+        return self._expression
+
+    def dimension(self) -> Optional[Dimension]:
+        """Returns the dimension marker, or `None` if the field is not a 
dimension."""
+        return self._dimension
+
+    def label(self) -> Optional[str]:
+        """Returns the display label, or `None` if it is not set."""
+        return self._label
+
+    def description(self) -> Optional[str]:
+        """Returns the field description, or `None` if it is not set."""
+        return self._description
+
+    def datatype(self) -> Optional[DataType]:
+        """Returns the logical data type, or `None` if it is not set."""
+        return self._datatype
+
+    def ai_context(self) -> Optional[AIContext]:
+        """Returns the AI context, or `None` if it is not set."""
+        return self._ai_context
+
+    def custom_extensions(self) -> Optional[list[CustomExtension]]:
+        """Returns the custom extensions, or `None` if they are not set."""
+        return (
+            None if self._custom_extensions is None else 
list(self._custom_extensions)
+        )
+
+    def __eq__(self, other: object) -> bool:
+        if not isinstance(other, Field):
+            return False
+        return (
+            self._name == other.name()
+            and self._expression == other.expression()
+            and self._dimension == other.dimension()
+            and self._label == other.label()
+            and self._description == other.description()
+            and self._datatype == other.datatype()
+            and self._ai_context == other.ai_context()
+            and self._custom_extensions == other.custom_extensions()
+        )
+
+    def __hash__(self) -> int:
+        return hash(
+            (
+                self._name,
+                self._expression,
+                self._dimension,
+                self._label,
+                self._description,
+                self._datatype,
+                self._ai_context,
+                (
+                    None
+                    if self._custom_extensions is None
+                    else tuple(self._custom_extensions)
+                ),
+            )
+        )
+
+    def __repr__(self) -> str:
+        return (
+            f"Field(name={self._name!r}, expression={self._expression!r}, "
+            f"dimension={self._dimension!r}, label={self._label!r}, "
+            f"description={self._description!r}, datatype={self._datatype!r}, "
+            f"aiContext={self._ai_context!r}, "
+            f"customExtensions={self._custom_extensions!r})"
+        )
diff --git a/clients/client-python/gravitino/api/semantic/metric.py 
b/clients/client-python/gravitino/api/semantic/metric.py
new file mode 100644
index 0000000000..b40b74b12f
--- /dev/null
+++ b/clients/client-python/gravitino/api/semantic/metric.py
@@ -0,0 +1,122 @@
+# 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.ai_context import AIContext
+from gravitino.api.semantic.custom_extension import CustomExtension
+from gravitino.api.semantic.data_type import DataType
+from gravitino.api.semantic.expression import Expression
+from gravitino.api.semantic.semantic_utils import check_no_none_elements
+from gravitino.utils.precondition import Precondition
+
+
+class Metric:
+    """A governed measure defined by a Semantic Model.
+
+    Metric names are unique within a Semantic Model. Metrics may reference 
fields
+    and datasets in the same Semantic Model, cross-model references are not
+    defined by this contract.
+    """
+
+    def __init__(
+        self,
+        name: str,
+        expression: Expression,
+        description: Optional[str] = None,
+        datatype: Optional[DataType] = None,
+        ai_context: Optional[AIContext] = None,
+        custom_extensions: Optional[list[CustomExtension]] = None,
+    ):
+        Precondition.check_argument(
+            name is not None and name != "", "name must not be null or empty"
+        )
+        Precondition.check_argument(
+            expression is not None, "expression must not be null"
+        )
+        check_no_none_elements("customExtensions", custom_extensions)
+
+        self._name = name
+        self._expression = expression
+        self._description = description
+        self._datatype = datatype
+        self._ai_context = ai_context
+        self._custom_extensions = (
+            None if custom_extensions is None else list(custom_extensions)
+        )
+
+    def name(self) -> str:
+        """Returns the metric name."""
+        return self._name
+
+    def expression(self) -> Expression:
+        """Returns the expression that computes the metric."""
+        return self._expression
+
+    def description(self) -> Optional[str]:
+        """Returns the metric description, or `None` if it is not set."""
+        return self._description
+
+    def datatype(self) -> Optional[DataType]:
+        """Returns the logical data type, or `None` if it is not set."""
+        return self._datatype
+
+    def ai_context(self) -> Optional[AIContext]:
+        """Returns the AI context, or `None` if it is not set."""
+        return self._ai_context
+
+    def custom_extensions(self) -> Optional[list[CustomExtension]]:
+        """Returns the custom extensions, or `None` if they are not set."""
+        return (
+            None if self._custom_extensions is None else 
list(self._custom_extensions)
+        )
+
+    def __eq__(self, other: object) -> bool:
+        if not isinstance(other, Metric):
+            return False
+        return (
+            self._name == other.name()
+            and self._expression == other.expression()
+            and self._description == other.description()
+            and self._datatype == other.datatype()
+            and self._ai_context == other.ai_context()
+            and self._custom_extensions == other.custom_extensions()
+        )
+
+    def __hash__(self) -> int:
+        return hash(
+            (
+                self._name,
+                self._expression,
+                self._description,
+                self._datatype,
+                self._ai_context,
+                (
+                    None
+                    if self._custom_extensions is None
+                    else tuple(self._custom_extensions)
+                ),
+            )
+        )
+
+    def __repr__(self) -> str:
+        return (
+            f"Metric(name={self._name!r}, expression={self._expression!r}, "
+            f"description={self._description!r}, datatype={self._datatype!r}, "
+            f"aiContext={self._ai_context!r}, "
+            f"customExtensions={self._custom_extensions!r})"
+        )
diff --git a/clients/client-python/gravitino/api/semantic/relationship.py 
b/clients/client-python/gravitino/api/semantic/relationship.py
new file mode 100644
index 0000000000..ba386d84b4
--- /dev/null
+++ b/clients/client-python/gravitino/api/semantic/relationship.py
@@ -0,0 +1,152 @@
+# 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.ai_context import AIContext
+from gravitino.api.semantic.custom_extension import CustomExtension
+from gravitino.api.semantic.semantic_utils import (
+    check_no_none_elements,
+    check_non_empty_string_elements,
+)
+from gravitino.utils.precondition import Precondition
+
+
+class Relationship:  # pylint: disable=too-many-instance-attributes
+    """A join between two datasets in the same Semantic Model.
+
+    Relationship names are unique within a Semantic Model. Both endpoints name 
a
+    dataset in the same Semantic Model, and the joined column lists are 
non-empty
+    and of equal length.
+
+    ``from_dataset`` and ``to_dataset`` map to the Ossie ``from`` and ``to``
+    fields, which cannot be used as Python identifiers.
+    """
+
+    def __init__(
+        self,
+        name: str,
+        from_dataset: str,
+        to_dataset: str,
+        from_columns: list[str],
+        to_columns: list[str],
+        ai_context: Optional[AIContext] = None,
+        custom_extensions: Optional[list[CustomExtension]] = None,
+    ):
+        Precondition.check_argument(
+            name is not None and name != "", "name must not be null or empty"
+        )
+        Precondition.check_argument(
+            from_dataset is not None and from_dataset != "",
+            "from must not be null or empty",
+        )
+        Precondition.check_argument(
+            to_dataset is not None and to_dataset != "", "to must not be null 
or empty"
+        )
+        Precondition.check_argument(
+            from_columns is not None and len(from_columns) > 0,
+            "fromColumns must not be null or empty",
+        )
+        Precondition.check_argument(
+            to_columns is not None and len(to_columns) > 0,
+            "toColumns must not be null or empty",
+        )
+        check_non_empty_string_elements("fromColumns", from_columns)
+        check_non_empty_string_elements("toColumns", to_columns)
+        Precondition.check_argument(
+            len(from_columns) == len(to_columns),
+            "fromColumns and toColumns must have the same length",
+        )
+        check_no_none_elements("customExtensions", custom_extensions)
+
+        self._name = name
+        self._from_dataset = from_dataset
+        self._to_dataset = to_dataset
+        self._from_columns = list(from_columns)
+        self._to_columns = list(to_columns)
+        self._ai_context = ai_context
+        self._custom_extensions = (
+            None if custom_extensions is None else list(custom_extensions)
+        )
+
+    def name(self) -> str:
+        """Returns the relationship name."""
+        return self._name
+
+    def from_dataset(self) -> str:
+        """Returns the name of the dataset the relationship joins from."""
+        return self._from_dataset
+
+    def to_dataset(self) -> str:
+        """Returns the name of the dataset the relationship joins to."""
+        return self._to_dataset
+
+    def from_columns(self) -> list[str]:
+        """Returns the joined columns exposed by the source dataset."""
+        return list(self._from_columns)
+
+    def to_columns(self) -> list[str]:
+        """Returns the joined columns exposed by the target dataset."""
+        return list(self._to_columns)
+
+    def ai_context(self) -> Optional[AIContext]:
+        """Returns the AI context, or `None` if it is not set."""
+        return self._ai_context
+
+    def custom_extensions(self) -> Optional[list[CustomExtension]]:
+        """Returns the custom extensions, or `None` if they are not set."""
+        return (
+            None if self._custom_extensions is None else 
list(self._custom_extensions)
+        )
+
+    def __eq__(self, other: object) -> bool:
+        if not isinstance(other, Relationship):
+            return False
+        return (
+            self._name == other.name()
+            and self._from_dataset == other.from_dataset()
+            and self._to_dataset == other.to_dataset()
+            and self._from_columns == other.from_columns()
+            and self._to_columns == other.to_columns()
+            and self._ai_context == other.ai_context()
+            and self._custom_extensions == other.custom_extensions()
+        )
+
+    def __hash__(self) -> int:
+        return hash(
+            (
+                self._name,
+                self._from_dataset,
+                self._to_dataset,
+                tuple(self._from_columns),
+                tuple(self._to_columns),
+                self._ai_context,
+                (
+                    None
+                    if self._custom_extensions is None
+                    else tuple(self._custom_extensions)
+                ),
+            )
+        )
+
+    def __repr__(self) -> str:
+        return (
+            f"Relationship(name={self._name!r}, from={self._from_dataset!r}, "
+            f"to={self._to_dataset!r}, fromColumns={self._from_columns!r}, "
+            f"toColumns={self._to_columns!r}, aiContext={self._ai_context!r}, "
+            f"customExtensions={self._custom_extensions!r})"
+        )
diff --git a/clients/client-python/gravitino/api/semantic/semantic_model.py 
b/clients/client-python/gravitino/api/semantic/semantic_model.py
new file mode 100644
index 0000000000..29501a847f
--- /dev/null
+++ b/clients/client-python/gravitino/api/semantic/semantic_model.py
@@ -0,0 +1,47 @@
+# 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 abstractmethod
+from typing import Optional
+
+from gravitino.api.auditable import Auditable
+from gravitino.api.semantic.semantic_model_definition import 
SemanticModelDefinition
+
+
+class SemanticModel(Auditable):
+    """A schema-scoped analytical semantic model compatible with Apache Ossie 
Core.
+
+    A Semantic Model is always managed by Gravitino and is never persisted in 
an
+    underlying catalog. It is a separate metadata type and lifecycle from the
+    Gravitino model, which represents an ML model artifact.
+    """
+
+    @abstractmethod
+    def name(self) -> str:
+        """Returns the Semantic Model name."""
+
+    def comment(self) -> Optional[str]:
+        """Returns the Semantic Model comment, `None` if it is not set."""
+        return None
+
+    @abstractmethod
+    def definition(self) -> SemanticModelDefinition:
+        """Returns the Ossie-compatible Semantic Model definition."""
+
+    def properties(self) -> dict[str, str]:
+        """Returns the Gravitino-specific Semantic Model properties."""
+        return {}
diff --git 
a/clients/client-python/gravitino/api/semantic/semantic_model_catalog.py 
b/clients/client-python/gravitino/api/semantic/semantic_model_catalog.py
new file mode 100644
index 0000000000..81b472a091
--- /dev/null
+++ b/clients/client-python/gravitino/api/semantic/semantic_model_catalog.py
@@ -0,0 +1,150 @@
+# 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 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.exceptions.base import NoSuchSemanticModelException
+from gravitino.name_identifier import NameIdentifier
+from gravitino.namespace import Namespace
+
+
+class SemanticModelCatalog(ABC):
+    """The `SemanticModelCatalog` interface defines the public API for managing
+    Semantic Models in a schema.
+
+    Semantic Models are always managed by Gravitino, so this support does not
+    depend on whether the underlying connector implements a semantic-model
+    capability.
+    """
+
+    @abstractmethod
+    def list_semantic_models(self, namespace: Namespace) -> 
list[NameIdentifier]:
+        """List the Semantic Models in a namespace from the catalog.
+
+        Identifiers rather than complete definitions are returned, so listing a
+        schema does not transfer every model body.
+
+        Args:
+            namespace (Namespace): A schema namespace.
+
+        Returns:
+            list[NameIdentifier]: The Semantic Model identifiers in the 
namespace.
+
+        Raises:
+            NoSuchSchemaException: If the schema does not exist.
+        """
+
+    @abstractmethod
+    def load_semantic_model(self, identifier: NameIdentifier) -> SemanticModel:
+        """Load Semantic Model metadata by `NameIdentifier` from the catalog.
+
+        Args:
+            identifier (NameIdentifier): A Semantic Model identifier.
+
+        Returns:
+            SemanticModel: The Semantic Model metadata.
+
+        Raises:
+            NoSuchSemanticModelException: If the Semantic Model does not exist.
+        """
+
+    def semantic_model_exists(self, identifier: NameIdentifier) -> bool:
+        """Check if a Semantic Model with the given name exists in the catalog.
+
+        Args:
+            identifier (NameIdentifier): A Semantic Model identifier.
+
+        Returns:
+            bool: `True` if the Semantic Model exists, `False` otherwise.
+        """
+        try:
+            self.load_semantic_model(identifier)
+            return True
+        except NoSuchSemanticModelException:
+            return False
+
+    @abstractmethod
+    def create_semantic_model(
+        self,
+        identifier: NameIdentifier,
+        comment: Optional[str],
+        definition: SemanticModelDefinition,
+        properties: Optional[dict[str, str]] = None,
+    ) -> SemanticModel:
+        """Create a Semantic Model in the catalog.
+
+        Args:
+            identifier (NameIdentifier):
+                A Semantic Model identifier.
+            comment (str, optional):
+                The Semantic Model comment.
+            definition (SemanticModelDefinition):
+                The complete Ossie-compatible definition.
+            properties (dict[str, str], optional):
+                The Gravitino-specific properties. Defaults to `None`.
+
+        Returns:
+            SemanticModel: The created Semantic Model metadata.
+
+        Raises:
+            NoSuchSchemaException:
+                If the schema does not exist.
+            SemanticModelAlreadyExistsException:
+                If the Semantic Model already exists.
+            IllegalSemanticModelException:
+                If the definition is invalid.
+        """
+
+    @abstractmethod
+    def alter_semantic_model(
+        self, identifier: NameIdentifier, *changes: SemanticModelChange
+    ) -> SemanticModel:
+        """Alter a Semantic Model in the catalog.
+
+        All changes are applied atomically to the current Semantic Model.
+
+        Args:
+            identifier (NameIdentifier): A Semantic Model identifier.
+            *changes: The Semantic Model changes to apply.
+
+        Returns:
+            SemanticModel: The updated Semantic Model metadata.
+
+        Raises:
+            NoSuchSemanticModelException:
+                If the Semantic Model does not exist.
+            SemanticModelAlreadyExistsException:
+                If a rename targets an existing Semantic Model name.
+            IllegalSemanticModelException:
+                If the resulting definition is invalid.
+        """
+
+    @abstractmethod
+    def drop_semantic_model(self, identifier: NameIdentifier) -> bool:
+        """Drop a Semantic Model from the catalog.
+
+        Args:
+            identifier (NameIdentifier): A Semantic Model identifier.
+
+        Returns:
+            bool:
+                `True` if the Semantic Model is dropped, `False` if it does 
not exist.
+        """
diff --git 
a/clients/client-python/gravitino/api/semantic/semantic_model_change.py 
b/clients/client-python/gravitino/api/semantic/semantic_model_change.py
new file mode 100644
index 0000000000..71959a8552
--- /dev/null
+++ b/clients/client-python/gravitino/api/semantic/semantic_model_change.py
@@ -0,0 +1,196 @@
+# 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
+from dataclasses import dataclass
+from typing import Optional
+
+from gravitino.api.semantic.semantic_model_definition import 
SemanticModelDefinition
+from gravitino.utils.precondition import Precondition
+
+
+class SemanticModelChange(ABC):
+    """Defines changes that can be applied to a Semantic Model.
+
+    Owner, tag, and policy changes use their existing governance stores and are
+    outside this contract.
+    """
+
+    @staticmethod
+    def rename(new_name: str) -> "RenameSemanticModel":
+        """Create a change for renaming a Semantic Model."""
+        return RenameSemanticModel(new_name)
+
+    @staticmethod
+    def update_comment(new_comment: Optional[str]) -> "UpdateComment":
+        """Create a change for updating or clearing a Semantic Model 
comment."""
+        return UpdateComment(new_comment)
+
+    @staticmethod
+    def set_property(property_name: str, value: str) -> "SetProperty":
+        """Create a change for setting a Semantic Model property."""
+        return SetProperty(property_name, value)
+
+    @staticmethod
+    def remove_property(property_name: str) -> "RemoveProperty":
+        """Create a change for removing a Semantic Model property."""
+        return RemoveProperty(property_name)
+
+    @staticmethod
+    def replace_definition(
+        definition: SemanticModelDefinition,
+    ) -> "ReplaceDefinition":
+        """Create a change for replacing the complete Semantic Model 
definition."""
+        return ReplaceDefinition(definition)
+
+
+@dataclass(frozen=True)
+class RenameSemanticModel(SemanticModelChange):
+    """A SemanticModelChange to rename a Semantic Model."""
+
+    _new_name: str
+
+    def __post_init__(self):
+        Precondition.check_string_not_empty(
+            self._new_name, "New name must not be null or blank"
+        )
+
+    def new_name(self) -> str:
+        """Returns the new Semantic Model name."""
+        return self._new_name
+
+    def __eq__(self, other: object) -> bool:
+        if not isinstance(other, RenameSemanticModel):
+            return False
+        return self._new_name == other.new_name()
+
+    def __hash__(self) -> int:
+        return hash(self._new_name)
+
+    def __str__(self) -> str:
+        return f"RENAMESEMANTICMODEL {self._new_name}"
+
+
+@dataclass(frozen=True)
+class UpdateComment(SemanticModelChange):
+    """A SemanticModelChange to update a Semantic Model comment."""
+
+    _new_comment: Optional[str]
+
+    def new_comment(self) -> Optional[str]:
+        """Returns the new comment, `None` clears the current comment."""
+        return self._new_comment
+
+    def __eq__(self, other: object) -> bool:
+        if not isinstance(other, UpdateComment):
+            return False
+        return self._new_comment == other.new_comment()
+
+    def __hash__(self) -> int:
+        return hash(self._new_comment)
+
+    def __str__(self) -> str:
+        return f"UPDATECOMMENT {'null' if self._new_comment is None else 
self._new_comment}"
+
+
+@dataclass(frozen=True)
+class SetProperty(SemanticModelChange):
+    """A SemanticModelChange to set a Semantic Model property."""
+
+    _property: str
+    _value: str
+
+    def __post_init__(self):
+        Precondition.check_string_not_empty(
+            self._property, "Property name must not be null or blank"
+        )
+        Precondition.check_argument(
+            self._value is not None, "Property value must not be null"
+        )
+
+    def property(self) -> str:
+        """Returns the property name."""
+        return self._property
+
+    def value(self) -> str:
+        """Returns the property value."""
+        return self._value
+
+    def __eq__(self, other: object) -> bool:
+        if not isinstance(other, SetProperty):
+            return False
+        return self._property == other.property() and self._value == 
other.value()
+
+    def __hash__(self) -> int:
+        return hash((self._property, self._value))
+
+    def __str__(self) -> str:
+        return f"SETPROPERTY {self._property} {self._value}"
+
+
+@dataclass(frozen=True)
+class RemoveProperty(SemanticModelChange):
+    """A SemanticModelChange to remove a Semantic Model property."""
+
+    _property: str
+
+    def __post_init__(self):
+        Precondition.check_string_not_empty(
+            self._property, "Property name must not be null or blank"
+        )
+
+    def property(self) -> str:
+        """Returns the property name."""
+        return self._property
+
+    def __eq__(self, other: object) -> bool:
+        if not isinstance(other, RemoveProperty):
+            return False
+        return self._property == other.property()
+
+    def __hash__(self) -> int:
+        return hash(self._property)
+
+    def __str__(self) -> str:
+        return f"REMOVEPROPERTY {self._property}"
+
+
+@dataclass(frozen=True)
+class ReplaceDefinition(SemanticModelChange):
+    """A SemanticModelChange to replace the complete Semantic Model 
definition."""
+
+    _definition: SemanticModelDefinition
+
+    def __post_init__(self):
+        Precondition.check_argument(
+            self._definition is not None, "Definition must not be null"
+        )
+
+    def definition(self) -> SemanticModelDefinition:
+        """Returns the replacement definition."""
+        return self._definition
+
+    def __eq__(self, other: object) -> bool:
+        if not isinstance(other, ReplaceDefinition):
+            return False
+        return self._definition == other.definition()
+
+    def __hash__(self) -> int:
+        return hash(self._definition)
+
+    def __str__(self) -> str:
+        return f"REPLACEDEFINITION {self._definition}"
diff --git 
a/clients/client-python/gravitino/api/semantic/semantic_model_definition.py 
b/clients/client-python/gravitino/api/semantic/semantic_model_definition.py
new file mode 100644
index 0000000000..0ca8d701e6
--- /dev/null
+++ b/clients/client-python/gravitino/api/semantic/semantic_model_definition.py
@@ -0,0 +1,116 @@
+# 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.ai_context import AIContext
+from gravitino.api.semantic.custom_extension import CustomExtension
+from gravitino.api.semantic.dataset import Dataset
+from gravitino.api.semantic.metric import Metric
+from gravitino.api.semantic.relationship import Relationship
+from gravitino.api.semantic.semantic_utils import check_no_none_elements
+from gravitino.utils.precondition import Precondition
+
+
+class SemanticModelDefinition:
+    """The immutable, Ossie-compatible body of a Semantic Model.
+
+    A definition has no name and no independent lifecycle, it is the value used
+    by the create, load, and replace operations. Collection order is preserved 
so
+    consumers can produce stable serialized output.
+    """
+
+    def __init__(
+        self,
+        datasets: list[Dataset],
+        ai_context: Optional[AIContext] = None,
+        relationships: Optional[list[Relationship]] = None,
+        metrics: Optional[list[Metric]] = None,
+        custom_extensions: Optional[list[CustomExtension]] = None,
+    ):
+        Precondition.check_argument(
+            datasets is not None and len(datasets) > 0,
+            "datasets must not be null or empty",
+        )
+        check_no_none_elements("datasets", datasets)
+        check_no_none_elements("relationships", relationships)
+        check_no_none_elements("metrics", metrics)
+        check_no_none_elements("customExtensions", custom_extensions)
+
+        self._datasets = list(datasets)
+        self._ai_context = ai_context
+        self._relationships = None if relationships is None else 
list(relationships)
+        self._metrics = None if metrics is None else list(metrics)
+        self._custom_extensions = (
+            None if custom_extensions is None else list(custom_extensions)
+        )
+
+    def ai_context(self) -> Optional[AIContext]:
+        """Returns the model-level AI context, or `None` if it is not set."""
+        return self._ai_context
+
+    def datasets(self) -> list[Dataset]:
+        """Returns the datasets, which always contain at least one entry."""
+        return list(self._datasets)
+
+    def relationships(self) -> Optional[list[Relationship]]:
+        """Returns the relationships, or `None` if they are not set."""
+        return None if self._relationships is None else 
list(self._relationships)
+
+    def metrics(self) -> Optional[list[Metric]]:
+        """Returns the metrics, or `None` if they are not set."""
+        return None if self._metrics is None else list(self._metrics)
+
+    def custom_extensions(self) -> Optional[list[CustomExtension]]:
+        """Returns the custom extensions, or `None` if they are not set."""
+        return (
+            None if self._custom_extensions is None else 
list(self._custom_extensions)
+        )
+
+    def __eq__(self, other: object) -> bool:
+        if not isinstance(other, SemanticModelDefinition):
+            return False
+        return (
+            self._ai_context == other.ai_context()
+            and self._datasets == other.datasets()
+            and self._relationships == other.relationships()
+            and self._metrics == other.metrics()
+            and self._custom_extensions == other.custom_extensions()
+        )
+
+    def __hash__(self) -> int:
+        return hash(
+            (
+                self._ai_context,
+                tuple(self._datasets),
+                None if self._relationships is None else 
tuple(self._relationships),
+                None if self._metrics is None else tuple(self._metrics),
+                (
+                    None
+                    if self._custom_extensions is None
+                    else tuple(self._custom_extensions)
+                ),
+            )
+        )
+
+    def __repr__(self) -> str:
+        return (
+            f"SemanticModelDefinition(aiContext={self._ai_context!r}, "
+            f"datasets={self._datasets!r}, 
relationships={self._relationships!r}, "
+            f"metrics={self._metrics!r}, "
+            f"customExtensions={self._custom_extensions!r})"
+        )
diff --git a/clients/client-python/gravitino/api/semantic/semantic_utils.py 
b/clients/client-python/gravitino/api/semantic/semantic_utils.py
new file mode 100644
index 0000000000..23ebef5da8
--- /dev/null
+++ b/clients/client-python/gravitino/api/semantic/semantic_utils.py
@@ -0,0 +1,59 @@
+# 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.
+
+"""Shared validation helpers for the Semantic Model value types."""
+
+from typing import Optional, Sequence
+
+from gravitino.utils.precondition import Precondition
+
+
+def check_no_none_elements(name: str, values: Optional[Sequence]) -> None:
+    """Check that an optional sequence does not contain `None` elements.
+
+    Args:
+        name (str): The name reported in the error message.
+        values (Sequence, optional): The sequence to check, `None` is allowed.
+
+    Raises:
+        IllegalArgumentException: If any element is `None`.
+    """
+    if values is None:
+        return
+    for index, value in enumerate(values):
+        Precondition.check_argument(
+            value is not None, f"{name}[{index}] must not be null"
+        )
+
+
+def check_non_empty_string_elements(name: str, values: 
Optional[Sequence[str]]) -> None:
+    """Check that an optional sequence only contains non-empty strings.
+
+    Args:
+        name (str): The name reported in the error message.
+        values (Sequence[str], optional): The sequence to check, `None` is 
allowed.
+
+    Raises:
+        IllegalArgumentException: If any element is not a string or is empty.
+    """
+    if values is None:
+        return
+    for index, value in enumerate(values):
+        Precondition.check_argument(
+            isinstance(value, str) and value != "",
+            f"{name}[{index}] must not be null or empty",
+        )
diff --git a/clients/client-python/gravitino/exceptions/base.py 
b/clients/client-python/gravitino/exceptions/base.py
index 42c0f91168..d632747d83 100644
--- a/clients/client-python/gravitino/exceptions/base.py
+++ b/clients/client-python/gravitino/exceptions/base.py
@@ -249,6 +249,18 @@ class 
FunctionAlreadyExistsException(AlreadyExistsException):
     """An exception thrown when a function already exists."""
 
 
+class NoSuchSemanticModelException(NotFoundException):
+    """An exception thrown when a Semantic Model with specified name is not 
found."""
+
+
+class SemanticModelAlreadyExistsException(AlreadyExistsException):
+    """An exception thrown when a Semantic Model already exists."""
+
+
+class IllegalSemanticModelException(IllegalArgumentException):
+    """An exception thrown when a Semantic Model definition is invalid."""
+
+
 class IllegalPrivilegeException(IllegalArgumentException):
     """An exception thrown when a privilege is invalid."""
 
diff --git a/clients/client-python/tests/unittests/api/semantic/__init__.py 
b/clients/client-python/tests/unittests/api/semantic/__init__.py
new file mode 100644
index 0000000000..13a83393a9
--- /dev/null
+++ b/clients/client-python/tests/unittests/api/semantic/__init__.py
@@ -0,0 +1,16 @@
+# 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.
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
new file mode 100644
index 0000000000..ccf6008e53
--- /dev/null
+++ 
b/clients/client-python/tests/unittests/api/semantic/test_semantic_model_catalog.py
@@ -0,0 +1,120 @@
+# 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 typing import Optional
+
+from gravitino.api.catalog import Catalog
+from gravitino.api.semantic.dataset import Dataset
+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 SemanticModelChange
+from gravitino.api.semantic.semantic_model_definition import 
SemanticModelDefinition
+from gravitino.dto.audit_dto import AuditDTO
+from gravitino.exceptions.base import (
+    NoSuchSemanticModelException,
+    UnsupportedOperationException,
+)
+from gravitino.name_identifier import NameIdentifier
+from gravitino.namespace import Namespace
+
+
+def _definition() -> SemanticModelDefinition:
+    return SemanticModelDefinition(
+        datasets=[Dataset("orders", NameIdentifier.of("sales", "mart", 
"orders"))]
+    )
+
+
+class _InMemorySemanticModel(SemanticModel):
+    def __init__(self, name: str, definition: SemanticModelDefinition):
+        self._name = name
+        self._definition = definition
+
+    def name(self) -> str:
+        return self._name
+
+    def definition(self) -> SemanticModelDefinition:
+        return self._definition
+
+    def audit_info(self) -> AuditDTO:
+        return AuditDTO(_creator="test")
+
+
+class _InMemorySemanticModelCatalog(SemanticModelCatalog):
+    def __init__(self):
+        self._models = {}
+
+    def list_semantic_models(self, namespace: Namespace) -> 
list[NameIdentifier]:
+        return [
+            NameIdentifier.of(namespace.level(0), name) for name in 
sorted(self._models)
+        ]
+
+    def load_semantic_model(self, identifier: NameIdentifier) -> SemanticModel:
+        model = self._models.get(identifier.name())
+        if model is None:
+            raise NoSuchSemanticModelException(
+                f"Semantic Model {identifier.name()} does not exist"
+            )
+        return model
+
+    def create_semantic_model(
+        self,
+        identifier: NameIdentifier,
+        comment: Optional[str],
+        definition: SemanticModelDefinition,
+        properties: Optional[dict[str, str]] = None,
+    ) -> SemanticModel:
+        model = _InMemorySemanticModel(identifier.name(), definition)
+        self._models[identifier.name()] = model
+        return model
+
+    def alter_semantic_model(
+        self, identifier: NameIdentifier, *changes: SemanticModelChange
+    ) -> SemanticModel:
+        return self.load_semantic_model(identifier)
+
+    def drop_semantic_model(self, identifier: NameIdentifier) -> bool:
+        return self._models.pop(identifier.name(), None) is not None
+
+
+class TestSemanticModelCatalog(unittest.TestCase):
+    def test_semantic_model_exists_uses_load(self):
+        catalog = _InMemorySemanticModelCatalog()
+        identifier = NameIdentifier.of("semantic", "sales_model")
+
+        self.assertFalse(catalog.semantic_model_exists(identifier))
+
+        catalog.create_semantic_model(identifier, "comment", _definition(), {})
+
+        self.assertTrue(catalog.semantic_model_exists(identifier))
+        self.assertTrue(catalog.drop_semantic_model(identifier))
+        self.assertFalse(catalog.semantic_model_exists(identifier))
+
+    def test_semantic_model_defaults(self):
+        model = _InMemorySemanticModel("sales_model", _definition())
+
+        self.assertEqual("sales_model", model.name())
+        self.assertIsNone(model.comment())
+        self.assertEqual({}, model.properties())
+        self.assertEqual(_definition(), model.definition())
+
+    def test_catalog_does_not_support_semantic_models_by_default(self):
+        with self.assertRaisesRegex(
+            UnsupportedOperationException,
+            "Catalog does not support semantic model operations",
+        ):
+            Catalog.as_semantic_model_catalog(None)
diff --git 
a/clients/client-python/tests/unittests/api/semantic/test_semantic_model_change.py
 
b/clients/client-python/tests/unittests/api/semantic/test_semantic_model_change.py
new file mode 100644
index 0000000000..6847d7331f
--- /dev/null
+++ 
b/clients/client-python/tests/unittests/api/semantic/test_semantic_model_change.py
@@ -0,0 +1,126 @@
+# 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 gravitino.api.semantic.dataset import Dataset
+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.name_identifier import NameIdentifier
+
+
+def _definition(dataset_name: str = "orders") -> SemanticModelDefinition:
+    return SemanticModelDefinition(
+        datasets=[
+            Dataset(dataset_name, NameIdentifier.of("sales", "mart", 
dataset_name))
+        ]
+    )
+
+
+class TestSemanticModelChange(unittest.TestCase):
+    def test_rename(self):
+        change = SemanticModelChange.rename("new_model")
+        equal_change = SemanticModelChange.rename("new_model")
+
+        self.assertIsInstance(change, RenameSemanticModel)
+        self.assertEqual("new_model", change.new_name())
+        self.assertEqual("RENAMESEMANTICMODEL new_model", str(change))
+        self.assertEqual(equal_change, change)
+        self.assertEqual(hash(equal_change), hash(change))
+        self.assertNotEqual(SemanticModelChange.rename("other_model"), change)
+        self.assertNotEqual(change, "invalid_change")
+
+        with self.assertRaisesRegex(ValueError, "New name must not be null or 
blank"):
+            SemanticModelChange.rename("")
+
+    def test_update_comment(self):
+        change = SemanticModelChange.update_comment("Updated sales 
definitions")
+        equal_change = SemanticModelChange.update_comment("Updated sales 
definitions")
+
+        self.assertIsInstance(change, UpdateComment)
+        self.assertEqual("Updated sales definitions", change.new_comment())
+        self.assertEqual("UPDATECOMMENT Updated sales definitions", 
str(change))
+        self.assertEqual(equal_change, change)
+        self.assertEqual(hash(equal_change), hash(change))
+        self.assertNotEqual(change, "invalid_change")
+
+    def test_update_comment_clears_the_comment(self):
+        change = SemanticModelChange.update_comment(None)
+
+        self.assertIsNone(change.new_comment())
+        self.assertNotEqual(SemanticModelChange.update_comment(""), change)
+
+    def test_set_property(self):
+        change = SemanticModelChange.set_property("key", "value")
+        equal_change = SemanticModelChange.set_property("key", "value")
+
+        self.assertIsInstance(change, SetProperty)
+        self.assertEqual("key", change.property())
+        self.assertEqual("value", change.value())
+        self.assertEqual("SETPROPERTY key value", str(change))
+        self.assertEqual(equal_change, change)
+        self.assertEqual(hash(equal_change), hash(change))
+        self.assertNotEqual(SemanticModelChange.set_property("key", "other"), 
change)
+        self.assertNotEqual(change, "invalid_change")
+
+        with self.assertRaisesRegex(
+            ValueError, "Property name must not be null or blank"
+        ):
+            SemanticModelChange.set_property("", "value")
+        with self.assertRaisesRegex(ValueError, "Property value must not be 
null"):
+            SemanticModelChange.set_property("key", None)
+
+    def test_remove_property(self):
+        change = SemanticModelChange.remove_property("key")
+        equal_change = SemanticModelChange.remove_property("key")
+
+        self.assertIsInstance(change, RemoveProperty)
+        self.assertEqual("key", change.property())
+        self.assertEqual("REMOVEPROPERTY key", str(change))
+        self.assertEqual(equal_change, change)
+        self.assertEqual(hash(equal_change), hash(change))
+        self.assertNotEqual(change, "invalid_change")
+
+        with self.assertRaisesRegex(
+            ValueError, "Property name must not be null or blank"
+        ):
+            SemanticModelChange.remove_property("")
+
+    def test_replace_definition(self):
+        definition = _definition()
+        change = SemanticModelChange.replace_definition(definition)
+        equal_change = SemanticModelChange.replace_definition(_definition())
+
+        self.assertIsInstance(change, ReplaceDefinition)
+        self.assertEqual(definition, change.definition())
+        self.assertEqual(f"REPLACEDEFINITION {definition}", str(change))
+        self.assertEqual(equal_change, change)
+        self.assertEqual(hash(equal_change), hash(change))
+        self.assertNotEqual(
+            SemanticModelChange.replace_definition(_definition("customers")), 
change
+        )
+        self.assertNotEqual(change, "invalid_change")
+
+        with self.assertRaisesRegex(ValueError, "Definition must not be null"):
+            SemanticModelChange.replace_definition(None)
diff --git 
a/clients/client-python/tests/unittests/api/semantic/test_semantic_model_definition.py
 
b/clients/client-python/tests/unittests/api/semantic/test_semantic_model_definition.py
new file mode 100644
index 0000000000..254a450a5c
--- /dev/null
+++ 
b/clients/client-python/tests/unittests/api/semantic/test_semantic_model_definition.py
@@ -0,0 +1,143 @@
+# 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 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.dialects import Dialects
+from gravitino.api.semantic.expression import Expression
+from gravitino.api.semantic.field import Field
+from gravitino.api.semantic.metric import Metric
+from gravitino.api.semantic.relationship import Relationship
+from gravitino.api.semantic.semantic_model_definition import 
SemanticModelDefinition
+from gravitino.name_identifier import NameIdentifier
+
+
+def _orders_dataset() -> Dataset:
+    order_amount = Field(
+        "order_amount",
+        Expression([DialectExpression(Dialects.ANSI_SQL, "order_amount")]),
+        datatype=DataType.DECIMAL,
+    )
+    return Dataset(
+        "orders",
+        NameIdentifier.of("sales", "mart", "orders"),
+        fields=[order_amount],
+    )
+
+
+def _customers_dataset() -> Dataset:
+    return Dataset("customers", NameIdentifier.of("sales", "mart", 
"customers"))
+
+
+class TestSemanticModelDefinition(unittest.TestCase):
+    def test_minimal_definition(self):
+        dataset = _orders_dataset()
+        definition = SemanticModelDefinition(datasets=[dataset])
+
+        self.assertEqual([dataset], definition.datasets())
+        self.assertIsNone(definition.ai_context())
+        self.assertIsNone(definition.relationships())
+        self.assertIsNone(definition.metrics())
+        self.assertIsNone(definition.custom_extensions())
+
+    def test_complete_definition(self):
+        orders = _orders_dataset()
+        customers = _customers_dataset()
+        relationship = Relationship(
+            "orders_to_customers", "orders", "customers", ["customer_id"], 
["id"]
+        )
+        metric = Metric(
+            "total_revenue",
+            Expression(
+                [DialectExpression(Dialects.ANSI_SQL, 
"SUM(orders.order_amount)")]
+            ),
+        )
+        ai_context = AIContext.of(
+            AIContextObject(
+                instructions="Use certified metrics only",
+                additional_properties={"audience": "finance"},
+            )
+        )
+        extension = CustomExtension("acme", "{}")
+
+        definition = SemanticModelDefinition(
+            datasets=[orders, customers],
+            ai_context=ai_context,
+            relationships=[relationship],
+            metrics=[metric],
+            custom_extensions=[extension],
+        )
+
+        self.assertEqual([orders, customers], definition.datasets())
+        self.assertEqual(ai_context, definition.ai_context())
+        self.assertEqual([relationship], definition.relationships())
+        self.assertEqual([metric], definition.metrics())
+        self.assertEqual([extension], definition.custom_extensions())
+
+    def test_definition_preserves_collection_order(self):
+        orders = _orders_dataset()
+        customers = _customers_dataset()
+
+        definition = SemanticModelDefinition(datasets=[orders, customers])
+        reversed_definition = SemanticModelDefinition(datasets=[customers, 
orders])
+
+        self.assertEqual([orders, customers], definition.datasets())
+        self.assertNotEqual(reversed_definition, definition)
+
+    def test_definition_returns_copies(self):
+        datasets = [_orders_dataset()]
+        definition = SemanticModelDefinition(datasets=datasets)
+
+        datasets.append(_customers_dataset())
+        definition.datasets().clear()
+
+        self.assertEqual(1, len(definition.datasets()))
+
+    def test_definition_equality(self):
+        definition = SemanticModelDefinition(datasets=[_orders_dataset()])
+        equal_definition = 
SemanticModelDefinition(datasets=[_orders_dataset()])
+
+        self.assertEqual(equal_definition, definition)
+        self.assertEqual(hash(equal_definition), hash(definition))
+        self.assertNotEqual(
+            SemanticModelDefinition(datasets=[_customers_dataset()]), 
definition
+        )
+        self.assertNotEqual(definition, "invalid")
+
+    def test_definition_rejects_invalid_arguments(self):
+        with self.assertRaisesRegex(ValueError, "datasets must not be null or 
empty"):
+            SemanticModelDefinition(datasets=[])
+        with self.assertRaisesRegex(ValueError, "datasets must not be null or 
empty"):
+            SemanticModelDefinition(datasets=None)
+        with self.assertRaisesRegex(ValueError, r"datasets\[0\] must not be 
null"):
+            SemanticModelDefinition(datasets=[None])
+        with self.assertRaisesRegex(ValueError, r"relationships\[0\] must not 
be null"):
+            SemanticModelDefinition(datasets=[_orders_dataset()], 
relationships=[None])
+        with self.assertRaisesRegex(ValueError, r"metrics\[0\] must not be 
null"):
+            SemanticModelDefinition(datasets=[_orders_dataset()], 
metrics=[None])
+        with self.assertRaisesRegex(
+            ValueError, r"customExtensions\[0\] must not be null"
+        ):
+            SemanticModelDefinition(
+                datasets=[_orders_dataset()], custom_extensions=[None]
+            )
diff --git 
a/clients/client-python/tests/unittests/api/semantic/test_semantic_model_members.py
 
b/clients/client-python/tests/unittests/api/semantic/test_semantic_model_members.py
new file mode 100644
index 0000000000..35dfe4377f
--- /dev/null
+++ 
b/clients/client-python/tests/unittests/api/semantic/test_semantic_model_members.py
@@ -0,0 +1,258 @@
+# 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 gravitino.api.semantic.ai_context import AIContext
+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.dialects import Dialects
+from gravitino.api.semantic.dimension import Dimension
+from gravitino.api.semantic.expression import Expression
+from gravitino.api.semantic.field import Field
+from gravitino.api.semantic.metric import Metric
+from gravitino.api.semantic.relationship import Relationship
+from gravitino.name_identifier import NameIdentifier
+
+
+def _expression(expression: str = "order_amount") -> Expression:
+    return Expression([DialectExpression(Dialects.ANSI_SQL, expression)])
+
+
+class TestSemanticModelMembers(unittest.TestCase):
+    def test_field_defaults(self):
+        field = Field("order_amount", _expression())
+
+        self.assertEqual("order_amount", field.name())
+        self.assertEqual(_expression(), field.expression())
+        self.assertIsNone(field.dimension())
+        self.assertIsNone(field.label())
+        self.assertIsNone(field.description())
+        self.assertIsNone(field.datatype())
+        self.assertIsNone(field.ai_context())
+        self.assertIsNone(field.custom_extensions())
+
+    def test_field_with_all_members(self):
+        extension = CustomExtension("acme", "{}")
+        field = Field(
+            name="order_date",
+            expression=_expression("order_date"),
+            dimension=Dimension(is_time=True),
+            label="Order date",
+            description="The date the order was placed",
+            datatype=DataType.DATE,
+            ai_context=AIContext.of("Order placement date"),
+            custom_extensions=[extension],
+        )
+
+        self.assertEqual(Dimension(is_time=True), field.dimension())
+        self.assertEqual("Order date", field.label())
+        self.assertEqual("The date the order was placed", field.description())
+        self.assertEqual(DataType.DATE, field.datatype())
+        self.assertEqual(AIContext.of("Order placement date"), 
field.ai_context())
+        self.assertEqual([extension], field.custom_extensions())
+
+    def test_field_equality(self):
+        field = Field("order_amount", _expression(), datatype=DataType.DECIMAL)
+        equal_field = Field("order_amount", _expression(), 
datatype=DataType.DECIMAL)
+
+        self.assertEqual(equal_field, field)
+        self.assertEqual(hash(equal_field), hash(field))
+        self.assertNotEqual(Field("order_amount", _expression()), field)
+        self.assertNotEqual(field, "invalid")
+
+    def test_field_rejects_invalid_arguments(self):
+        with self.assertRaisesRegex(ValueError, "name must not be null or 
empty"):
+            Field("", _expression())
+        with self.assertRaisesRegex(ValueError, "expression must not be null"):
+            Field("order_amount", None)
+        with self.assertRaisesRegex(
+            ValueError, r"customExtensions\[0\] must not be null"
+        ):
+            Field("order_amount", _expression(), custom_extensions=[None])
+
+    def test_dataset_defaults(self):
+        source = NameIdentifier.of("sales", "mart", "orders")
+        dataset = Dataset("orders", source)
+
+        self.assertEqual("orders", dataset.name())
+        self.assertEqual(source, dataset.source())
+        self.assertIsNone(dataset.primary_key())
+        self.assertIsNone(dataset.unique_keys())
+        self.assertIsNone(dataset.description())
+        self.assertIsNone(dataset.ai_context())
+        self.assertIsNone(dataset.fields())
+        self.assertIsNone(dataset.custom_extensions())
+
+    def test_dataset_with_all_members(self):
+        field = Field("order_amount", _expression())
+        dataset = Dataset(
+            name="orders",
+            source=NameIdentifier.of("sales", "mart", "orders"),
+            primary_key=["order_id"],
+            unique_keys=[["order_id"], ["customer_id", "order_date"]],
+            description="Order facts",
+            ai_context=AIContext.of("Certified order facts"),
+            fields=[field],
+            custom_extensions=[CustomExtension("acme", "{}")],
+        )
+
+        self.assertEqual(["order_id"], dataset.primary_key())
+        self.assertEqual(
+            [["order_id"], ["customer_id", "order_date"]], 
dataset.unique_keys()
+        )
+        self.assertEqual("Order facts", dataset.description())
+        self.assertEqual([field], dataset.fields())
+
+    def test_dataset_returns_copies(self):
+        primary_key = ["order_id"]
+        unique_keys = [["order_id"]]
+        fields = [Field("order_amount", _expression())]
+        dataset = Dataset(
+            "orders",
+            NameIdentifier.of("sales", "mart", "orders"),
+            primary_key=primary_key,
+            unique_keys=unique_keys,
+            fields=fields,
+        )
+
+        primary_key.append("mutated")
+        unique_keys[0].append("mutated")
+        fields.clear()
+        dataset.primary_key().clear()
+        dataset.unique_keys()[0].clear()
+        dataset.fields().clear()
+
+        self.assertEqual(["order_id"], dataset.primary_key())
+        self.assertEqual([["order_id"]], dataset.unique_keys())
+        self.assertEqual(1, len(dataset.fields()))
+
+    def test_dataset_rejects_invalid_arguments(self):
+        source = NameIdentifier.of("sales", "mart", "orders")
+
+        with self.assertRaisesRegex(ValueError, "name must not be null or 
empty"):
+            Dataset("", source)
+        with self.assertRaisesRegex(ValueError, "source must not be null"):
+            Dataset("orders", None)
+        with self.assertRaisesRegex(
+            ValueError, r"primaryKey\[0\] must not be null or empty"
+        ):
+            Dataset("orders", source, primary_key=[""])
+        with self.assertRaisesRegex(
+            ValueError, r"uniqueKeys\[0\] must not be null or empty"
+        ):
+            Dataset("orders", source, unique_keys=[[]])
+        with self.assertRaisesRegex(
+            ValueError, r"uniqueKeys\[1\]\[0\] must not be null or empty"
+        ):
+            Dataset("orders", source, unique_keys=[["order_id"], [None]])
+        with self.assertRaisesRegex(ValueError, r"fields\[0\] must not be 
null"):
+            Dataset("orders", source, fields=[None])
+
+    def test_metric(self):
+        metric = Metric(
+            name="total_revenue",
+            expression=_expression("SUM(orders.order_amount)"),
+            description="Total revenue across all orders",
+            datatype=DataType.DECIMAL,
+        )
+
+        self.assertEqual("total_revenue", metric.name())
+        self.assertEqual(_expression("SUM(orders.order_amount)"), 
metric.expression())
+        self.assertEqual("Total revenue across all orders", 
metric.description())
+        self.assertEqual(DataType.DECIMAL, metric.datatype())
+        self.assertIsNone(metric.ai_context())
+        self.assertIsNone(metric.custom_extensions())
+
+    def test_metric_equality(self):
+        metric = Metric("total_revenue", _expression())
+        equal_metric = Metric("total_revenue", _expression())
+
+        self.assertEqual(equal_metric, metric)
+        self.assertEqual(hash(equal_metric), hash(metric))
+        self.assertNotEqual(Metric("other", _expression()), metric)
+        self.assertNotEqual(metric, "invalid")
+
+    def test_metric_rejects_invalid_arguments(self):
+        with self.assertRaisesRegex(ValueError, "name must not be null or 
empty"):
+            Metric("", _expression())
+        with self.assertRaisesRegex(ValueError, "expression must not be null"):
+            Metric("total_revenue", None)
+
+    def test_relationship(self):
+        relationship = Relationship(
+            name="orders_to_customers",
+            from_dataset="orders",
+            to_dataset="customers",
+            from_columns=["customer_id"],
+            to_columns=["id"],
+        )
+
+        self.assertEqual("orders_to_customers", relationship.name())
+        self.assertEqual("orders", relationship.from_dataset())
+        self.assertEqual("customers", relationship.to_dataset())
+        self.assertEqual(["customer_id"], relationship.from_columns())
+        self.assertEqual(["id"], relationship.to_columns())
+        self.assertIsNone(relationship.ai_context())
+        self.assertIsNone(relationship.custom_extensions())
+
+    def test_relationship_returns_copies(self):
+        from_columns = ["customer_id"]
+        relationship = Relationship(
+            "orders_to_customers", "orders", "customers", from_columns, ["id"]
+        )
+
+        from_columns.append("mutated")
+        relationship.from_columns().clear()
+
+        self.assertEqual(["customer_id"], relationship.from_columns())
+
+    def test_relationship_equality(self):
+        relationship = Relationship(
+            "orders_to_customers", "orders", "customers", ["customer_id"], 
["id"]
+        )
+        equal_relationship = Relationship(
+            "orders_to_customers", "orders", "customers", ["customer_id"], 
["id"]
+        )
+
+        self.assertEqual(equal_relationship, relationship)
+        self.assertEqual(hash(equal_relationship), hash(relationship))
+        self.assertNotEqual(relationship, "invalid")
+
+    def test_relationship_rejects_invalid_arguments(self):
+        with self.assertRaisesRegex(ValueError, "name must not be null or 
empty"):
+            Relationship("", "orders", "customers", ["customer_id"], ["id"])
+        with self.assertRaisesRegex(ValueError, "from must not be null or 
empty"):
+            Relationship("rel", "", "customers", ["customer_id"], ["id"])
+        with self.assertRaisesRegex(ValueError, "to must not be null or 
empty"):
+            Relationship("rel", "orders", "", ["customer_id"], ["id"])
+        with self.assertRaisesRegex(
+            ValueError, "fromColumns must not be null or empty"
+        ):
+            Relationship("rel", "orders", "customers", [], ["id"])
+        with self.assertRaisesRegex(ValueError, "toColumns must not be null or 
empty"):
+            Relationship("rel", "orders", "customers", ["customer_id"], [])
+        with self.assertRaisesRegex(
+            ValueError, r"fromColumns\[0\] must not be null or empty"
+        ):
+            Relationship("rel", "orders", "customers", [""], ["id"])
+        with self.assertRaisesRegex(
+            ValueError, "fromColumns and toColumns must have the same length"
+        ):
+            Relationship("rel", "orders", "customers", ["customer_id"], ["id", 
"extra"])
diff --git 
a/clients/client-python/tests/unittests/api/semantic/test_semantic_model_supporting_types.py
 
b/clients/client-python/tests/unittests/api/semantic/test_semantic_model_supporting_types.py
new file mode 100644
index 0000000000..22fe689033
--- /dev/null
+++ 
b/clients/client-python/tests/unittests/api/semantic/test_semantic_model_supporting_types.py
@@ -0,0 +1,265 @@
+# 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 gravitino.api.semantic.ai_context import AIContext
+from gravitino.api.semantic.ai_context_object import (
+    MAX_ADDITIONAL_PROPERTY_NESTING_DEPTH,
+    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.dialects import Dialects
+from gravitino.api.semantic.dimension import Dimension
+from gravitino.api.semantic.expression import Expression
+
+
+class TestSemanticModelSupportingTypes(unittest.TestCase):
+    def test_data_type_uses_ossie_wire_values(self):
+        self.assertEqual("Decimal", DataType.DECIMAL.value)
+        self.assertEqual("DateTime", DataType.DATE_TIME.value)
+        self.assertEqual("DateTimeTz", DataType.DATE_TIME_TZ.value)
+        self.assertEqual(DataType.OPAQUE, DataType("Opaque"))
+
+    def test_dialects_are_case_sensitive_constants(self):
+        self.assertEqual("ANSI_SQL", Dialects.ANSI_SQL)
+        self.assertEqual("BIGQUERY", Dialects.BIGQUERY)
+        with self.assertRaises(TypeError):
+            Dialects()
+
+    def test_dialect_expression(self):
+        dialect_expression = DialectExpression(Dialects.ANSI_SQL, 
"order_amount")
+        equal_expression = DialectExpression(Dialects.ANSI_SQL, "order_amount")
+
+        self.assertEqual(Dialects.ANSI_SQL, dialect_expression.dialect())
+        self.assertEqual("order_amount", dialect_expression.expression())
+        self.assertEqual(dialect_expression, equal_expression)
+        self.assertEqual(hash(dialect_expression), hash(equal_expression))
+        self.assertNotEqual(dialect_expression, "invalid")
+        self.assertNotEqual(
+            dialect_expression, DialectExpression("ansi_sql", "order_amount")
+        )
+
+    def test_dialect_expression_rejects_empty_fields(self):
+        with self.assertRaisesRegex(ValueError, "dialect must not be null or 
empty"):
+            DialectExpression("", "order_amount")
+        with self.assertRaisesRegex(ValueError, "expression must not be null 
or empty"):
+            DialectExpression(Dialects.ANSI_SQL, "")
+
+    def test_expression_preserves_dialect_order(self):
+        ansi = DialectExpression(Dialects.ANSI_SQL, "order_amount")
+        snowflake = DialectExpression(Dialects.SNOWFLAKE, "ORDER_AMOUNT")
+        expression = Expression([ansi, snowflake])
+
+        self.assertEqual([ansi, snowflake], expression.dialects())
+        self.assertEqual(Expression([ansi, snowflake]), expression)
+        self.assertNotEqual(Expression([snowflake, ansi]), expression)
+
+    def test_expression_returns_a_copy_of_its_dialects(self):
+        ansi = DialectExpression(Dialects.ANSI_SQL, "order_amount")
+        expression = Expression([ansi])
+
+        expression.dialects().clear()
+
+        self.assertEqual([ansi], expression.dialects())
+
+    def test_expression_rejects_invalid_dialects(self):
+        ansi = DialectExpression(Dialects.ANSI_SQL, "order_amount")
+
+        with self.assertRaisesRegex(ValueError, "dialects must not be null or 
empty"):
+            Expression([])
+        with self.assertRaisesRegex(ValueError, r"dialects\[0\] must not be 
null"):
+            Expression([None])
+        with self.assertRaisesRegex(
+            ValueError, "dialects must not contain duplicate dialect: ANSI_SQL"
+        ):
+            Expression([ansi, DialectExpression(Dialects.ANSI_SQL, "amount")])
+
+    def test_dimension(self):
+        self.assertIsNone(Dimension().is_time())
+        self.assertTrue(Dimension(is_time=True).is_time())
+        self.assertEqual(Dimension(True), Dimension(True))
+        self.assertEqual(hash(Dimension(True)), hash(Dimension(True)))
+        self.assertNotEqual(Dimension(True), Dimension(False))
+        self.assertNotEqual(Dimension(True), "invalid")
+
+    def test_custom_extension(self):
+        extension = CustomExtension("acme", '{"unit": "usd"}')
+
+        self.assertEqual("acme", extension.vendor_name())
+        self.assertEqual('{"unit": "usd"}', extension.data())
+        self.assertEqual(CustomExtension("acme", '{"unit": "usd"}'), extension)
+        self.assertNotEqual(CustomExtension("other", '{"unit": "usd"}'), 
extension)
+        self.assertNotEqual(extension, "invalid")
+
+    def test_custom_extension_rejects_missing_fields(self):
+        with self.assertRaisesRegex(ValueError, "vendorName must not be null"):
+            CustomExtension(None, "data")
+        with self.assertRaisesRegex(ValueError, "data must not be null"):
+            CustomExtension("acme", None)
+
+    def test_ai_context_holds_text(self):
+        ai_context = AIContext.of("Use certified metrics only")
+
+        self.assertTrue(ai_context.is_text())
+        self.assertEqual("Use certified metrics only", ai_context.text())
+        self.assertIsNone(ai_context.object())
+        self.assertEqual(AIContext.of("Use certified metrics only"), 
ai_context)
+        self.assertEqual(
+            hash(AIContext.of("Use certified metrics only")), hash(ai_context)
+        )
+        self.assertNotEqual(ai_context, "invalid")
+
+    def test_ai_context_holds_object(self):
+        ai_context_object = AIContextObject(instructions="Use certified 
metrics only")
+        ai_context = AIContext.of(ai_context_object)
+
+        self.assertFalse(ai_context.is_text())
+        self.assertIsNone(ai_context.text())
+        self.assertEqual(ai_context_object, ai_context.object())
+
+    def test_ai_context_rejects_unsupported_values(self):
+        with self.assertRaisesRegex(
+            ValueError, "AI context must be a string or an AIContextObject"
+        ):
+            AIContext.of(None)
+        with self.assertRaisesRegex(
+            ValueError, "AI context must be a string or an AIContextObject"
+        ):
+            AIContext.of(42)
+        with self.assertRaisesRegex(
+            ValueError, "AI context must contain exactly one of text or object"
+        ):
+            AIContext(None, None)
+        with self.assertRaisesRegex(
+            ValueError, "AI context must contain exactly one of text or object"
+        ):
+            AIContext("text", AIContextObject())
+
+    def test_ai_context_object_defaults(self):
+        ai_context_object = AIContextObject()
+
+        self.assertIsNone(ai_context_object.instructions())
+        self.assertIsNone(ai_context_object.synonyms())
+        self.assertIsNone(ai_context_object.examples())
+        self.assertEqual({}, ai_context_object.additional_properties())
+
+    def test_ai_context_object_retains_additional_properties(self):
+        ai_context_object = AIContextObject(
+            instructions="Use certified metrics only",
+            synonyms=["sales"],
+            examples=["total revenue by month"],
+            additional_properties={
+                "audience": "finance",
+                "thresholds": {"warn": 10, "limits": [1, 2.5, True, None]},
+            },
+        )
+
+        self.assertEqual("Use certified metrics only", 
ai_context_object.instructions())
+        self.assertEqual(["sales"], ai_context_object.synonyms())
+        self.assertEqual(["total revenue by month"], 
ai_context_object.examples())
+        self.assertEqual(
+            {
+                "audience": "finance",
+                "thresholds": {"warn": 10, "limits": [1, 2.5, True, None]},
+            },
+            ai_context_object.additional_properties(),
+        )
+
+    def test_ai_context_object_returns_copies(self):
+        synonyms = ["sales"]
+        additional_properties = {"nested": {"key": "value"}}
+        ai_context_object = AIContextObject(
+            synonyms=synonyms, additional_properties=additional_properties
+        )
+
+        synonyms.append("revenue")
+        additional_properties["nested"]["key"] = "mutated"
+        ai_context_object.synonyms().clear()
+        ai_context_object.additional_properties()["nested"]["key"] = "mutated"
+
+        self.assertEqual(["sales"], ai_context_object.synonyms())
+        self.assertEqual(
+            {"nested": {"key": "value"}}, 
ai_context_object.additional_properties()
+        )
+
+    def test_ai_context_object_equality(self):
+        ai_context_object = AIContextObject(
+            instructions="instructions", additional_properties={"audience": 
"finance"}
+        )
+        equal_object = AIContextObject(
+            instructions="instructions", additional_properties={"audience": 
"finance"}
+        )
+
+        self.assertEqual(equal_object, ai_context_object)
+        self.assertEqual(hash(equal_object), hash(ai_context_object))
+        self.assertNotEqual(AIContextObject(instructions="other"), 
ai_context_object)
+        self.assertNotEqual(ai_context_object, "invalid")
+
+    def test_ai_context_object_rejects_invalid_additional_properties(self):
+        with self.assertRaisesRegex(
+            ValueError,
+            "additional property must not duplicate standard property: 
synonyms",
+        ):
+            AIContextObject(additional_properties={"synonyms": ["sales"]})
+
+        with self.assertRaisesRegex(
+            ValueError, "additional property name must be a string"
+        ):
+            AIContextObject(additional_properties={1: "value"})
+
+        with self.assertRaisesRegex(
+            ValueError,
+            "Additional property audience has non-JSON-compatible value type: 
object",
+        ):
+            AIContextObject(additional_properties={"audience": object()})
+
+        with self.assertRaisesRegex(
+            ValueError, "Additional property ratio must contain a finite 
number"
+        ):
+            AIContextObject(additional_properties={"ratio": float("nan")})
+
+        with self.assertRaisesRegex(
+            ValueError,
+            r"Additional property nested contains a map key that is not a 
string",
+        ):
+            AIContextObject(additional_properties={"nested": {1: "value"}})
+
+    def test_ai_context_object_rejects_cyclic_additional_properties(self):
+        cyclic = {}
+        cyclic["self"] = cyclic
+
+        with self.assertRaisesRegex(
+            ValueError, r"Additional property nested\.self contains a cyclic 
value"
+        ):
+            AIContextObject(additional_properties={"nested": cyclic})
+
+    def 
test_ai_context_object_rejects_deeply_nested_additional_properties(self):
+        value = "leaf"
+        for _ in range(MAX_ADDITIONAL_PROPERTY_NESTING_DEPTH + 1):
+            value = [value]
+
+        with self.assertRaisesRegex(ValueError, "exceeds maximum nesting depth 
of 100"):
+            AIContextObject(additional_properties={"nested": value})
+
+    def test_ai_context_object_rejects_none_elements(self):
+        with self.assertRaisesRegex(ValueError, r"synonyms\[1\] must be a 
string"):
+            AIContextObject(synonyms=["sales", None])
+        with self.assertRaisesRegex(ValueError, r"examples\[0\] must be a 
string"):
+            AIContextObject(examples=[None])
diff --git 
a/clients/client-python/tests/unittests/api/semantic/test_semantic_model_value_semantics.py
 
b/clients/client-python/tests/unittests/api/semantic/test_semantic_model_value_semantics.py
new file mode 100644
index 0000000000..c79a43b39a
--- /dev/null
+++ 
b/clients/client-python/tests/unittests/api/semantic/test_semantic_model_value_semantics.py
@@ -0,0 +1,220 @@
+# 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, localcontext
+
+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.custom_extension import CustomExtension
+from gravitino.api.semantic.dimension import Dimension
+from gravitino.api.semantic.relationship import Relationship
+from gravitino.api.semantic.semantic_model_change import SemanticModelChange
+from gravitino.api.semantic.semantic_model_definition import 
SemanticModelDefinition
+from gravitino.exceptions.base import IllegalArgumentException
+from gravitino.name_identifier import NameIdentifier
+
+
+class TestSemanticModelValueSemantics(unittest.TestCase):
+    def test_source_copies_on_input_and_output(self):
+        for mutate_input in (True, False):
+            with self.subTest(mutate_input=mutate_input):
+                source = NameIdentifier.of("catalog", "schema", "orders")
+                dataset = Dataset("orders", source)
+                definition = SemanticModelDefinition([dataset])
+                expected = SemanticModelDefinition(
+                    [
+                        Dataset(
+                            "orders", NameIdentifier.of("catalog", "schema", 
"orders")
+                        )
+                    ]
+                )
+                original_hash = hash(definition)
+                models = {definition: "saved"}
+                exposed = source if mutate_input else dataset.source()
+                exposed.namespace().levels()[0] = "other"
+                exposed._name = "changed"  # pylint: disable=protected-access
+                self.assertEqual(expected, definition)
+                self.assertEqual(original_hash, hash(definition))
+                self.assertEqual("saved", models[expected])
+
+    def test_json_boolean_and_number_are_distinct_recursively(self):
+        for boolean, number in ((True, 1), (False, 0), (True, 1.0)):
+            for wrap in (lambda x: x, lambda x: [x], lambda x: {"nested": 
[x]}):
+                with self.subTest(boolean=boolean, number=number, wrap=wrap):
+                    left = AIContextObject(
+                        additional_properties={"value": wrap(boolean)}
+                    )
+                    right = AIContextObject(
+                        additional_properties={"value": wrap(number)}
+                    )
+                    self.assertNotEqual(left, right)
+                    dataset = Dataset(
+                        "orders", NameIdentifier.of("catalog", "schema", 
"orders")
+                    )
+                    self.assertNotEqual(
+                        SemanticModelDefinition(
+                            [dataset], ai_context=AIContext.of(left)
+                        ),
+                        SemanticModelDefinition(
+                            [dataset], ai_context=AIContext.of(right)
+                        ),
+                    )
+
+    def test_json_hash_includes_values_and_ignores_object_order(self):
+        left = AIContextObject(additional_properties={"a": [1, {"b": True}], 
"z": None})
+        right = AIContextObject(
+            additional_properties={"z": None, "a": (1, {"b": True})}
+        )
+        self.assertEqual(left, right)
+        self.assertEqual(hash(left), hash(right))
+        hashes = {
+            hash(AIContextObject(additional_properties={"value": n}))
+            for n in range(100)
+        }
+        self.assertGreater(len(hashes), 90)
+        self.assertNotEqual(
+            AIContextObject(additional_properties={"value": []}),
+            AIContextObject(additional_properties={"value": {}}),
+        )
+
+    def test_ai_context_rejects_non_string_elements(self):
+        for field in ("synonyms", "examples"):
+            for value in (1, True, {}, [], {"term": "sales"}, None):
+                with self.subTest(field=field, value=value):
+                    with self.assertRaises(IllegalArgumentException):
+                        AIContextObject(**{field: [value]})
+            context = AIContextObject(**{field: [""]})
+            self.assertEqual([""], getattr(context, field)())
+            hash(context)
+
+    def test_keys_and_relationship_columns_reject_non_strings(self):
+        source = NameIdentifier.of("catalog", "schema", "orders")
+        for value in (1, True, {}, []):
+            for build in (
+                lambda value=value: Dataset("orders", source, 
primary_key=[value]),
+                lambda value=value: Dataset("orders", source, 
unique_keys=[[value]]),
+                lambda value=value: Relationship(
+                    "r", "orders", "customers", [value], ["id"]
+                ),
+                lambda value=value: Relationship(
+                    "r", "orders", "customers", ["id"], [value]
+                ),
+            ):
+                with self.subTest(value=value, build=build):
+                    with self.assertRaises(IllegalArgumentException):
+                        build()
+
+    def test_clear_comment_display(self):
+        self.assertEqual(
+            "UPDATECOMMENT null", str(SemanticModelChange.update_comment(None))
+        )
+
+    def test_scalar_types_are_validated(self):
+        for value in ([], {}, 1, True):
+            for build in (
+                lambda value=value: AIContextObject(instructions=value),
+                lambda value=value: CustomExtension(value, "data"),
+                lambda value=value: CustomExtension("vendor", value),
+            ):
+                with self.subTest(value=value, build=build):
+                    with self.assertRaises(IllegalArgumentException):
+                        build()
+        for value in ([], {}, 0, 1, 1.0, "true"):
+            with self.subTest(is_time=value):
+                with self.assertRaises(IllegalArgumentException):
+                    Dimension(value)
+        for value in (None, "", "instructions"):
+            self.assertEqual(value, 
AIContextObject(instructions=value).instructions())
+        self.assertEqual("", CustomExtension("", "").data())
+        for value in (None, False, True):
+            self.assertIs(value, Dimension(value).is_time())
+
+    def test_decimal_precision_and_value_semantics(self):
+        value = Decimal("123456789.123456789012345678901234567890123456789")
+        with localcontext() as context:
+            context.prec = 6
+            left = AIContextObject(additional_properties={"value": [{"nested": 
value}]})
+            right = AIContextObject(
+                additional_properties={"value": [{"nested": 
Decimal(str(value))}]}
+            )
+            returned = left.additional_properties()["value"][0]["nested"]
+            self.assertIsInstance(returned, Decimal)
+            self.assertEqual(value.as_tuple(), returned.as_tuple())
+            self.assertEqual(left, right)
+            self.assertEqual(hash(left), hash(right))
+            self.assertEqual("saved", {left: "saved"}[right])
+            changed = AIContextObject(
+                additional_properties={
+                    "value": [
+                        {
+                            "nested": Decimal(
+                                
"123456789.123456789012345678901234567890123456788"
+                            )
+                        }
+                    ]
+                }
+            )
+            self.assertNotEqual(left, changed)
+        self.assertNotEqual(
+            AIContextObject(additional_properties={"value": Decimal(1)}),
+            AIContextObject(additional_properties={"value": True}),
+        )
+
+    def test_non_finite_decimals_are_rejected(self):
+        for value in ("NaN", "sNaN", "Infinity", "-Infinity"):
+            with self.subTest(value=value):
+                with self.assertRaisesRegex(IllegalArgumentException, "finite 
number"):
+                    AIContextObject(additional_properties={"nested": 
[Decimal(value)]})
+
+    def test_float_decimal_json_round_trip_value_semantics(self):
+        for value in (2.5, 0.1, 1.0, -0.0, 1e-100, 1.2345678901234567):
+            with self.subTest(value=value), localcontext() as context:
+                context.prec = 6
+                original = AIContextObject(
+                    additional_properties={"nested": [{"value": value}]}
+                )
+                restored = AIContextObject(
+                    additional_properties=json.loads(
+                        json.dumps(original.additional_properties()),
+                        parse_float=Decimal,
+                    )
+                )
+                self.assertEqual(original, restored)
+                self.assertEqual(hash(original), hash(restored))
+                self.assertEqual("saved", {original: "saved"}[restored])
+                dataset = Dataset(
+                    "orders", NameIdentifier.of("catalog", "schema", "orders")
+                )
+                definition = SemanticModelDefinition(
+                    [dataset], ai_context=AIContext.of(original)
+                )
+                restored_definition = SemanticModelDefinition(
+                    [dataset], ai_context=AIContext.of(restored)
+                )
+                self.assertEqual(definition, restored_definition)
+                self.assertEqual(hash(definition), hash(restored_definition))
+                self.assertEqual("saved", {definition: 
"saved"}[restored_definition])
+        contexts = [
+            AIContextObject(additional_properties={"value": value})
+            for value in (True, 1, 1.0, Decimal("1.0"))
+        ]
+        self.assertEqual(3, len(set(contexts)))
+        self.assertEqual(contexts[2], contexts[3])

Reply via email to