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 ba47e2225a [#12613] feat(client-python): Add Semantic Model DTOs and 
serialization (#12859)
ba47e2225a is described below

commit ba47e2225afe7c27d60a6b0bdee3c7c53d3317be
Author: Akshay Thorat <[email protected]>
AuthorDate: Thu Oct 1 09:02:23 2026 -0700

    [#12613] feat(client-python): Add Semantic Model DTOs and serialization 
(#12859)
    
    ### What changes were proposed in this pull request?
    
    Add Python Semantic Model DTOs and bidirectional conversion helpers for
    the public API merged in #12858. This PR now builds directly on `main`
    and contains only the DTO and serialization changes.
    
    - Cover semantic types, model definitions, and model metadata.
    - Match the Java wire representation for Java camelCase field names,
    datatype values, dataset source identifiers, and omitted optional
    fields.
    - Serialize AI context as a string or an object, preserving additional
    object properties and exact decimal numbers.
    
    REST catalog operations are covered separately by #12860.
    
    ### Why are the changes needed?
    
    The Python client needs a serialization layer between the Semantic Model
    API types and the server's JSON wire format.
    
    Fix: #12613
    
    ### Does this PR introduce _any_ user-facing change?
    
    No new catalog operations. This adds internal DTOs and serialization
    support.
    
    ### How was this patch tested?
    
    The DTO tests cover exact JSON representations, API/DTO conversions,
    JSON round-trips, both AI-context shapes, additional properties, and
    invalid definitions and datatypes.
    
    Review regressions cover the Java camelCase wire contract, lossless
    nested decimal numbers, explicit nulls for optional AI-context fields,
    and recursive API-consistent equality and hashing. Decimal JSON encoding
    uses `simplejson`.
    
    The full Python unit suite passed: 1162 tests. Black and Pylint checks
    passed.
    
    ```sh
    cd clients/client-python
    python -m pytest tests/unittests -q --tb=short
    black --check gravitino/dto/semantic tests/unittests/dto/semantic
    pylint gravitino/dto/semantic tests/unittests/dto/semantic
    ```
---
 .../dto/semantic/__init__.py}                      |  18 +-
 .../gravitino/dto/semantic/ai_context_dto.py       |  77 ++++++
 .../dto/semantic/ai_context_object_dto.py          |  97 +++++++
 .../gravitino/dto/semantic/custom_extension_dto.py |  56 ++++
 .../gravitino/dto/semantic/dataset_dto.py          | 139 ++++++++++
 .../dto/semantic/dialect_expression_dto.py         |  59 ++++
 .../gravitino/dto/semantic/dimension_dto.py        |  48 ++++
 .../gravitino/dto/semantic/expression_dto.py       |  58 ++++
 .../gravitino/dto/semantic/field_dto.py            | 155 +++++++++++
 .../dto/semantic/json_serdes/__init__.py}          |  18 +-
 .../dto/semantic/json_serdes/ai_context_serdes.py  | 104 +++++++
 .../dto/semantic/json_serdes/data_type_serdes.py   |  47 ++++
 .../semantic/json_serdes/semantic_json_mixin.py    |  74 +++++
 .../gravitino/dto/semantic/metric_dto.py           | 129 +++++++++
 .../gravitino/dto/semantic/relationship_dto.py     | 127 +++++++++
 .../gravitino/dto/semantic/semantic_dto_utils.py   |  47 ++++
 .../dto/semantic/semantic_model_definition_dto.py  | 122 ++++++++
 .../gravitino/dto/semantic/semantic_model_dto.py   |  75 +++++
 clients/client-python/requirements.txt             |   1 +
 .../unittests/dto/semantic/__init__.py}            |  18 +-
 .../semantic/test_semantic_model_definition_dto.py | 247 +++++++++++++++++
 .../dto/semantic/test_semantic_model_dto.py        | 110 ++++++++
 .../semantic/test_semantic_wire_compatibility.py   | 307 +++++++++++++++++++++
 23 files changed, 2082 insertions(+), 51 deletions(-)

diff --git a/clients/client-python/requirements.txt 
b/clients/client-python/gravitino/dto/semantic/__init__.py
similarity index 69%
copy from clients/client-python/requirements.txt
copy to clients/client-python/gravitino/dto/semantic/__init__.py
index 57353994fe..13a83393a9 100644
--- a/clients/client-python/requirements.txt
+++ b/clients/client-python/gravitino/dto/semantic/__init__.py
@@ -6,7 +6,7 @@
 # "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
+#   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
@@ -14,19 +14,3 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-
-
-# the tools to publish the python client to Pypi
-requests==2.32.5
-dataclasses-json==0.6.7
-readerwriterlock==1.0.9
-fsspec==2026.2.0
-pyarrow==23.0.1
-cachetools==7.1.4
-gcsfs==2026.2.0
-s3fs==2026.2.0
-ossfs==2025.5.0
-adlfs==2026.2.0
-black==26.3.1
-flake8==7.0.0
-pre-commit==3.5.0
diff --git a/clients/client-python/gravitino/dto/semantic/ai_context_dto.py 
b/clients/client-python/gravitino/dto/semantic/ai_context_dto.py
new file mode 100644
index 0000000000..bffe63b5c8
--- /dev/null
+++ b/clients/client-python/gravitino/dto/semantic/ai_context_dto.py
@@ -0,0 +1,77 @@
+# 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.dto.semantic.ai_context_object_dto import AIContextObjectDTO
+from gravitino.utils.precondition import Precondition
+
+
+class AIContextDTO:
+    """Represents an AI context DTO holding exactly one of a string or a
+    structured object.
+
+    A string AI context is serialized as a bare JSON string, a structured AI
+    context is serialized as a JSON object.
+    """
+
+    def __init__(
+        self,
+        text: Optional[str] = None,
+        obj: Optional[AIContextObjectDTO] = None,
+    ):
+        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
+
+    def text(self) -> Optional[str]:
+        """Returns the string value, or `None` if this holds a structured 
object."""
+        return self._text
+
+    def object(self) -> Optional[AIContextObjectDTO]:
+        """Returns the structured value, or `None` if this holds a string."""
+        return self._object
+
+    @staticmethod
+    def from_ai_context(ai_context: AIContext) -> "AIContextDTO":
+        """Convert an AI context to its DTO."""
+        if ai_context.is_text():
+            return AIContextDTO(text=ai_context.text())
+        return AIContextDTO(
+            obj=AIContextObjectDTO.from_ai_context_object(ai_context.object())
+        )
+
+    def to_ai_context(self) -> AIContext:
+        """Convert this DTO to an AI context."""
+        if self._text is not None:
+            return AIContext.of(self._text)
+        return AIContext.of(self._object.to_ai_context_object())
+
+    def __eq__(self, other: object) -> bool:
+        if not isinstance(other, AIContextDTO):
+            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"AIContextDTO(text={self._text!r}, object={self._object!r})"
diff --git 
a/clients/client-python/gravitino/dto/semantic/ai_context_object_dto.py 
b/clients/client-python/gravitino/dto/semantic/ai_context_object_dto.py
new file mode 100644
index 0000000000..df8b765c06
--- /dev/null
+++ b/clients/client-python/gravitino/dto/semantic/ai_context_object_dto.py
@@ -0,0 +1,97 @@
+# 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
+from typing import Any, Optional
+
+from gravitino.api.semantic.ai_context_object import AIContextObject
+
+
+class AIContextObjectDTO:
+    """Represents a structured AI context DTO.
+
+    Unknown properties are carried in `additional_properties` and are flattened
+    next to the standard properties on the wire.
+    """
+
+    def __init__(
+        self,
+        instructions: Optional[str] = None,
+        synonyms: Optional[list[str]] = None,
+        examples: Optional[list[str]] = None,
+        additional_properties: Optional[dict[str, Any]] = None,
+    ):
+        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 = (
+            {}
+            if additional_properties is None
+            else copy.deepcopy(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 properties, empty if none are set."""
+        return copy.deepcopy(self._additional_properties)
+
+    @staticmethod
+    def from_ai_context_object(
+        ai_context_object: AIContextObject,
+    ) -> "AIContextObjectDTO":
+        """Convert a structured AI context to its DTO."""
+        return AIContextObjectDTO(
+            instructions=ai_context_object.instructions(),
+            synonyms=ai_context_object.synonyms(),
+            examples=ai_context_object.examples(),
+            additional_properties=ai_context_object.additional_properties(),
+        )
+
+    def to_ai_context_object(self) -> AIContextObject:
+        """Convert this DTO to a structured AI context."""
+        return AIContextObject(
+            instructions=self._instructions,
+            synonyms=self._synonyms,
+            examples=self._examples,
+            additional_properties=self._additional_properties,
+        )
+
+    def __eq__(self, other: object) -> bool:
+        if not isinstance(other, AIContextObjectDTO):
+            return False
+        return self.to_ai_context_object() == other.to_ai_context_object()
+
+    def __hash__(self) -> int:
+        return hash(self.to_ai_context_object())
+
+    def __repr__(self) -> str:
+        return (
+            f"AIContextObjectDTO(instructions={self._instructions!r}, "
+            f"synonyms={self._synonyms!r}, examples={self._examples!r}, "
+            f"additionalProperties={self._additional_properties!r})"
+        )
diff --git 
a/clients/client-python/gravitino/dto/semantic/custom_extension_dto.py 
b/clients/client-python/gravitino/dto/semantic/custom_extension_dto.py
new file mode 100644
index 0000000000..75bc45a913
--- /dev/null
+++ b/clients/client-python/gravitino/dto/semantic/custom_extension_dto.py
@@ -0,0 +1,56 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from dataclasses import dataclass, field
+from typing import Optional
+
+from dataclasses_json import config
+
+from gravitino.api.semantic.custom_extension import CustomExtension
+from gravitino.dto.semantic.json_serdes.semantic_json_mixin import 
SemanticJsonMixin
+from gravitino.dto.semantic.semantic_dto_utils import is_none
+
+
+@dataclass
+class CustomExtensionDTO(SemanticJsonMixin):
+    """Represents a vendor-specific custom extension DTO."""
+
+    _vendor_name: Optional[str] = field(
+        default=None, metadata=config(field_name="vendorName", exclude=is_none)
+    )
+    _data: Optional[str] = field(
+        default=None, metadata=config(field_name="data", exclude=is_none)
+    )
+
+    def vendor_name(self) -> Optional[str]:
+        """Returns the vendor name that owns this extension."""
+        return self._vendor_name
+
+    def data(self) -> Optional[str]:
+        """Returns the opaque extension data."""
+        return self._data
+
+    @staticmethod
+    def from_custom_extension(extension: CustomExtension) -> 
"CustomExtensionDTO":
+        """Convert a custom extension to its DTO."""
+        return CustomExtensionDTO(
+            _vendor_name=extension.vendor_name(), _data=extension.data()
+        )
+
+    def to_custom_extension(self) -> CustomExtension:
+        """Convert this DTO to a custom extension."""
+        return CustomExtension(self._vendor_name, self._data)
diff --git a/clients/client-python/gravitino/dto/semantic/dataset_dto.py 
b/clients/client-python/gravitino/dto/semantic/dataset_dto.py
new file mode 100644
index 0000000000..46faddceb2
--- /dev/null
+++ b/clients/client-python/gravitino/dto/semantic/dataset_dto.py
@@ -0,0 +1,139 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from dataclasses import dataclass, field
+from typing import Optional
+
+from dataclasses_json import config
+
+from gravitino.api.semantic.dataset import Dataset
+from gravitino.dto.semantic.json_serdes.semantic_json_mixin import 
SemanticJsonMixin
+from gravitino.dto.semantic.ai_context_dto import AIContextDTO
+from gravitino.dto.semantic.custom_extension_dto import CustomExtensionDTO
+from gravitino.dto.semantic.field_dto import FieldDTO
+from gravitino.dto.semantic.json_serdes.ai_context_serdes import 
AIContextSerdes
+from gravitino.dto.semantic.semantic_dto_utils import convert_list, is_none
+from gravitino.name_identifier import NameIdentifier
+
+
+@dataclass
+class DatasetDTO(SemanticJsonMixin):  # pylint: 
disable=too-many-instance-attributes
+    """Represents a Semantic Model dataset DTO."""
+
+    _name: Optional[str] = field(
+        default=None, metadata=config(field_name="name", exclude=is_none)
+    )
+    _source: Optional[NameIdentifier] = field(
+        default=None, metadata=config(field_name="source", exclude=is_none)
+    )
+    _primary_key: Optional[list[str]] = field(
+        default=None,
+        metadata=config(field_name="primaryKey", exclude=is_none),
+    )
+    _unique_keys: Optional[list[list[str]]] = field(
+        default=None,
+        metadata=config(field_name="uniqueKeys", exclude=is_none),
+    )
+    _description: Optional[str] = field(
+        default=None,
+        metadata=config(field_name="description", exclude=is_none),
+    )
+    _ai_context: Optional[AIContextDTO] = field(
+        default=None,
+        metadata=config(
+            field_name="aiContext",
+            encoder=AIContextSerdes.serialize,
+            decoder=AIContextSerdes.deserialize,
+            exclude=is_none,
+        ),
+    )
+    _fields: Optional[list[FieldDTO]] = field(
+        default=None,
+        metadata=config(field_name="fields", exclude=is_none),
+    )
+    _custom_extensions: Optional[list[CustomExtensionDTO]] = field(
+        default=None,
+        metadata=config(field_name="customExtensions", exclude=is_none),
+    )
+
+    def name(self) -> Optional[str]:
+        """Returns the dataset name."""
+        return self._name
+
+    def source(self) -> Optional[NameIdentifier]:
+        """Returns the identifier of the table or view backing the dataset."""
+        return self._source
+
+    def primary_key(self) -> Optional[list[str]]:
+        """Returns the primary key columns, or `None` if they are not set."""
+        return 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 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[AIContextDTO]:
+        """Returns the AI context, or `None` if it is not set."""
+        return self._ai_context
+
+    def fields(self) -> Optional[list[FieldDTO]]:
+        """Returns the dataset fields, or `None` if they are not set."""
+        return self._fields
+
+    def custom_extensions(self) -> Optional[list[CustomExtensionDTO]]:
+        """Returns the custom extensions, or `None` if they are not set."""
+        return self._custom_extensions
+
+    @staticmethod
+    def from_dataset(dataset: Dataset) -> "DatasetDTO":
+        """Convert a dataset to its DTO."""
+        ai_context = dataset.ai_context()
+        return DatasetDTO(
+            _name=dataset.name(),
+            _source=dataset.source(),
+            _primary_key=dataset.primary_key(),
+            _unique_keys=dataset.unique_keys(),
+            _description=dataset.description(),
+            _ai_context=(
+                None if ai_context is None else 
AIContextDTO.from_ai_context(ai_context)
+            ),
+            _fields=convert_list(dataset.fields(), FieldDTO.from_field),
+            _custom_extensions=convert_list(
+                dataset.custom_extensions(), 
CustomExtensionDTO.from_custom_extension
+            ),
+        )
+
+    def to_dataset(self) -> Dataset:
+        """Convert this DTO to a dataset."""
+        return Dataset(
+            name=self._name,
+            source=self._source,
+            primary_key=self._primary_key,
+            unique_keys=self._unique_keys,
+            description=self._description,
+            ai_context=(
+                None if self._ai_context is None else 
self._ai_context.to_ai_context()
+            ),
+            fields=convert_list(self._fields, FieldDTO.to_field),
+            custom_extensions=convert_list(
+                self._custom_extensions, CustomExtensionDTO.to_custom_extension
+            ),
+        )
diff --git 
a/clients/client-python/gravitino/dto/semantic/dialect_expression_dto.py 
b/clients/client-python/gravitino/dto/semantic/dialect_expression_dto.py
new file mode 100644
index 0000000000..dad828171a
--- /dev/null
+++ b/clients/client-python/gravitino/dto/semantic/dialect_expression_dto.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.
+
+from dataclasses import dataclass, field
+from typing import Optional
+
+from dataclasses_json import config
+
+from gravitino.api.semantic.dialect_expression import DialectExpression
+from gravitino.dto.semantic.json_serdes.semantic_json_mixin import 
SemanticJsonMixin
+from gravitino.dto.semantic.semantic_dto_utils import is_none
+
+
+@dataclass
+class DialectExpressionDTO(SemanticJsonMixin):
+    """Represents one dialect-specific rendering of an expression."""
+
+    _dialect: Optional[str] = field(
+        default=None, metadata=config(field_name="dialect", exclude=is_none)
+    )
+    _expression: Optional[str] = field(
+        default=None, metadata=config(field_name="expression", exclude=is_none)
+    )
+
+    def dialect(self) -> Optional[str]:
+        """Returns the dialect identifier."""
+        return self._dialect
+
+    def expression(self) -> Optional[str]:
+        """Returns the expression rendered in this dialect."""
+        return self._expression
+
+    @staticmethod
+    def from_dialect_expression(
+        dialect_expression: DialectExpression,
+    ) -> "DialectExpressionDTO":
+        """Convert a dialect expression to its DTO."""
+        return DialectExpressionDTO(
+            _dialect=dialect_expression.dialect(),
+            _expression=dialect_expression.expression(),
+        )
+
+    def to_dialect_expression(self) -> DialectExpression:
+        """Convert this DTO to a dialect expression."""
+        return DialectExpression(self._dialect, self._expression)
diff --git a/clients/client-python/gravitino/dto/semantic/dimension_dto.py 
b/clients/client-python/gravitino/dto/semantic/dimension_dto.py
new file mode 100644
index 0000000000..7de8f60d19
--- /dev/null
+++ b/clients/client-python/gravitino/dto/semantic/dimension_dto.py
@@ -0,0 +1,48 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from dataclasses import dataclass, field
+from typing import Optional
+
+from dataclasses_json import config
+
+from gravitino.api.semantic.dimension import Dimension
+from gravitino.dto.semantic.json_serdes.semantic_json_mixin import 
SemanticJsonMixin
+from gravitino.dto.semantic.semantic_dto_utils import is_none
+
+
+@dataclass
+class DimensionDTO(SemanticJsonMixin):
+    """Represents a Semantic Model dimension marker DTO."""
+
+    _is_time: Optional[bool] = field(
+        default=None,
+        metadata=config(field_name="isTime", exclude=is_none),
+    )
+
+    def is_time(self) -> Optional[bool]:
+        """Returns whether the dimension is a time dimension."""
+        return self._is_time
+
+    @staticmethod
+    def from_dimension(dimension: Dimension) -> "DimensionDTO":
+        """Convert a dimension to its DTO."""
+        return DimensionDTO(_is_time=dimension.is_time())
+
+    def to_dimension(self) -> Dimension:
+        """Convert this DTO to a dimension."""
+        return Dimension(self._is_time)
diff --git a/clients/client-python/gravitino/dto/semantic/expression_dto.py 
b/clients/client-python/gravitino/dto/semantic/expression_dto.py
new file mode 100644
index 0000000000..c8539364f3
--- /dev/null
+++ b/clients/client-python/gravitino/dto/semantic/expression_dto.py
@@ -0,0 +1,58 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from dataclasses import dataclass, field
+from typing import Optional
+
+from dataclasses_json import config
+
+from gravitino.api.semantic.expression import Expression
+from gravitino.dto.semantic.json_serdes.semantic_json_mixin import 
SemanticJsonMixin
+from gravitino.dto.semantic.dialect_expression_dto import DialectExpressionDTO
+from gravitino.dto.semantic.semantic_dto_utils import convert_list, is_none
+from gravitino.utils.precondition import Precondition
+
+
+@dataclass
+class ExpressionDTO(SemanticJsonMixin):
+    """Represents a Semantic Model expression DTO."""
+
+    _dialects: Optional[list[DialectExpressionDTO]] = field(
+        default=None, metadata=config(field_name="dialects", exclude=is_none)
+    )
+
+    def dialects(self) -> Optional[list[DialectExpressionDTO]]:
+        """Returns the dialect-specific renderings of this expression."""
+        return self._dialects
+
+    @staticmethod
+    def from_expression(expression: Expression) -> "ExpressionDTO":
+        """Convert an expression to its DTO."""
+        return ExpressionDTO(
+            _dialects=convert_list(
+                expression.dialects(), 
DialectExpressionDTO.from_dialect_expression
+            )
+        )
+
+    def to_expression(self) -> Expression:
+        """Convert this DTO to an expression."""
+        Precondition.check_argument(
+            self._dialects is not None, "dialects must not be null"
+        )
+        return Expression(
+            convert_list(self._dialects, 
DialectExpressionDTO.to_dialect_expression)
+        )
diff --git a/clients/client-python/gravitino/dto/semantic/field_dto.py 
b/clients/client-python/gravitino/dto/semantic/field_dto.py
new file mode 100644
index 0000000000..cc0fa8061a
--- /dev/null
+++ b/clients/client-python/gravitino/dto/semantic/field_dto.py
@@ -0,0 +1,155 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from dataclasses import dataclass, field
+from typing import Optional
+
+from dataclasses_json import config
+
+from gravitino.api.semantic.data_type import DataType
+from gravitino.api.semantic.field import Field
+from gravitino.dto.semantic.json_serdes.semantic_json_mixin import 
SemanticJsonMixin
+from gravitino.dto.semantic.ai_context_dto import AIContextDTO
+from gravitino.dto.semantic.custom_extension_dto import CustomExtensionDTO
+from gravitino.dto.semantic.dimension_dto import DimensionDTO
+from gravitino.dto.semantic.expression_dto import ExpressionDTO
+from gravitino.dto.semantic.json_serdes.ai_context_serdes import 
AIContextSerdes
+from gravitino.dto.semantic.json_serdes.data_type_serdes import DataTypeSerdes
+from gravitino.dto.semantic.semantic_dto_utils import convert_list, is_none
+from gravitino.utils.precondition import Precondition
+
+
+@dataclass
+class FieldDTO(SemanticJsonMixin):  # pylint: 
disable=too-many-instance-attributes
+    """Represents a Semantic Model field DTO."""
+
+    _name: Optional[str] = field(
+        default=None, metadata=config(field_name="name", exclude=is_none)
+    )
+    _expression: Optional[ExpressionDTO] = field(
+        default=None, metadata=config(field_name="expression", exclude=is_none)
+    )
+    _dimension: Optional[DimensionDTO] = field(
+        default=None,
+        metadata=config(field_name="dimension", exclude=is_none),
+    )
+    _label: Optional[str] = field(
+        default=None,
+        metadata=config(field_name="label", exclude=is_none),
+    )
+    _description: Optional[str] = field(
+        default=None,
+        metadata=config(field_name="description", exclude=is_none),
+    )
+    _datatype: Optional[DataType] = field(
+        default=None,
+        metadata=config(
+            field_name="datatype",
+            encoder=DataTypeSerdes.serialize,
+            decoder=DataTypeSerdes.deserialize,
+            exclude=is_none,
+        ),
+    )
+    _ai_context: Optional[AIContextDTO] = field(
+        default=None,
+        metadata=config(
+            field_name="aiContext",
+            encoder=AIContextSerdes.serialize,
+            decoder=AIContextSerdes.deserialize,
+            exclude=is_none,
+        ),
+    )
+    _custom_extensions: Optional[list[CustomExtensionDTO]] = field(
+        default=None,
+        metadata=config(field_name="customExtensions", exclude=is_none),
+    )
+
+    def name(self) -> Optional[str]:
+        """Returns the field name."""
+        return self._name
+
+    def expression(self) -> Optional[ExpressionDTO]:
+        """Returns the expression that produces the field."""
+        return self._expression
+
+    def dimension(self) -> Optional[DimensionDTO]:
+        """Returns the dimension marker, or `None` if it is not set."""
+        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[AIContextDTO]:
+        """Returns the AI context, or `None` if it is not set."""
+        return self._ai_context
+
+    def custom_extensions(self) -> Optional[list[CustomExtensionDTO]]:
+        """Returns the custom extensions, or `None` if they are not set."""
+        return self._custom_extensions
+
+    @staticmethod
+    def from_field(source: Field) -> "FieldDTO":
+        """Convert a field to its DTO."""
+        dimension = source.dimension()
+        ai_context = source.ai_context()
+        return FieldDTO(
+            _name=source.name(),
+            _expression=ExpressionDTO.from_expression(source.expression()),
+            _dimension=(
+                None if dimension is None else 
DimensionDTO.from_dimension(dimension)
+            ),
+            _label=source.label(),
+            _description=source.description(),
+            _datatype=source.datatype(),
+            _ai_context=(
+                None if ai_context is None else 
AIContextDTO.from_ai_context(ai_context)
+            ),
+            _custom_extensions=convert_list(
+                source.custom_extensions(), 
CustomExtensionDTO.from_custom_extension
+            ),
+        )
+
+    def to_field(self) -> Field:
+        """Convert this DTO to a field."""
+        Precondition.check_argument(
+            self._expression is not None, "expression must not be null"
+        )
+        return Field(
+            name=self._name,
+            expression=self._expression.to_expression(),
+            dimension=(
+                None if self._dimension is None else 
self._dimension.to_dimension()
+            ),
+            label=self._label,
+            description=self._description,
+            datatype=self._datatype,
+            ai_context=(
+                None if self._ai_context is None else 
self._ai_context.to_ai_context()
+            ),
+            custom_extensions=convert_list(
+                self._custom_extensions, CustomExtensionDTO.to_custom_extension
+            ),
+        )
diff --git a/clients/client-python/requirements.txt 
b/clients/client-python/gravitino/dto/semantic/json_serdes/__init__.py
similarity index 69%
copy from clients/client-python/requirements.txt
copy to clients/client-python/gravitino/dto/semantic/json_serdes/__init__.py
index 57353994fe..13a83393a9 100644
--- a/clients/client-python/requirements.txt
+++ b/clients/client-python/gravitino/dto/semantic/json_serdes/__init__.py
@@ -6,7 +6,7 @@
 # "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
+#   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
@@ -14,19 +14,3 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-
-
-# the tools to publish the python client to Pypi
-requests==2.32.5
-dataclasses-json==0.6.7
-readerwriterlock==1.0.9
-fsspec==2026.2.0
-pyarrow==23.0.1
-cachetools==7.1.4
-gcsfs==2026.2.0
-s3fs==2026.2.0
-ossfs==2025.5.0
-adlfs==2026.2.0
-black==26.3.1
-flake8==7.0.0
-pre-commit==3.5.0
diff --git 
a/clients/client-python/gravitino/dto/semantic/json_serdes/ai_context_serdes.py 
b/clients/client-python/gravitino/dto/semantic/json_serdes/ai_context_serdes.py
new file mode 100644
index 0000000000..353c964c20
--- /dev/null
+++ 
b/clients/client-python/gravitino/dto/semantic/json_serdes/ai_context_serdes.py
@@ -0,0 +1,104 @@
+# 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 Any, Optional, Union
+
+from gravitino.dto.semantic.ai_context_dto import AIContextDTO
+from gravitino.dto.semantic.ai_context_object_dto import AIContextObjectDTO
+from gravitino.utils.precondition import Precondition
+
+_INSTRUCTIONS = "instructions"
+_SYNONYMS = "synonyms"
+_EXAMPLES = "examples"
+
+
+class AIContextSerdes:
+    """Serdes for AI context DTOs.
+
+    An AI context is either a bare JSON string or a JSON object whose unknown
+    properties are retained losslessly.
+    """
+
+    @staticmethod
+    def serialize(
+        value: Optional[AIContextDTO],
+    ) -> Optional[Union[str, dict[str, Any]]]:
+        """Encode an AI context DTO to a string or a dictionary."""
+        if value is None:
+            return None
+        if value.text() is not None:
+            return value.text()
+        return _serialize_object(value.object())
+
+    @staticmethod
+    def deserialize(
+        value: Optional[Union[str, dict[str, Any]]],
+    ) -> Optional[AIContextDTO]:
+        """Decode an AI context DTO from a string or a dictionary."""
+        if value is None:
+            return None
+        if isinstance(value, str):
+            return AIContextDTO(text=value)
+        Precondition.check_argument(
+            isinstance(value, dict), "AI context must be a string or object"
+        )
+        return AIContextDTO(obj=_deserialize_object(value))
+
+
+def _serialize_object(value: AIContextObjectDTO) -> dict[str, Any]:
+    serialized: dict[str, Any] = {}
+    if value.instructions() is not None:
+        serialized[_INSTRUCTIONS] = value.instructions()
+    if value.synonyms() is not None:
+        serialized[_SYNONYMS] = value.synonyms()
+    if value.examples() is not None:
+        serialized[_EXAMPLES] = value.examples()
+    serialized.update(value.additional_properties())
+    return serialized
+
+
+def _deserialize_object(value: dict[str, Any]) -> AIContextObjectDTO:
+    additional_properties = {
+        name: item
+        for name, item in value.items()
+        if name not in (_INSTRUCTIONS, _SYNONYMS, _EXAMPLES)
+    }
+    return AIContextObjectDTO(
+        instructions=_read_string(value, _INSTRUCTIONS),
+        synonyms=_read_string_list(value, _SYNONYMS),
+        examples=_read_string_list(value, _EXAMPLES),
+        additional_properties=additional_properties,
+    )
+
+
+def _read_string(value: dict[str, Any], name: str) -> Optional[str]:
+    if value.get(name) is None:
+        return None
+    item = value[name]
+    Precondition.check_argument(isinstance(item, str), f"{name} must be a 
string")
+    return item
+
+
+def _read_string_list(value: dict[str, Any], name: str) -> Optional[list[str]]:
+    if value.get(name) is None:
+        return None
+    items = value[name]
+    Precondition.check_argument(
+        isinstance(items, list) and all(isinstance(item, str) for item in 
items),
+        f"{name} must be an array of strings",
+    )
+    return list(items)
diff --git 
a/clients/client-python/gravitino/dto/semantic/json_serdes/data_type_serdes.py 
b/clients/client-python/gravitino/dto/semantic/json_serdes/data_type_serdes.py
new file mode 100644
index 0000000000..1026f4dc07
--- /dev/null
+++ 
b/clients/client-python/gravitino/dto/semantic/json_serdes/data_type_serdes.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 typing import Optional
+
+from gravitino.api.semantic.data_type import DataType
+from gravitino.exceptions.base import IllegalArgumentException
+from gravitino.utils.precondition import Precondition
+
+
+class DataTypeSerdes:
+    """Serdes for the Semantic Model logical data type vocabulary."""
+
+    @staticmethod
+    def serialize(value: Optional[DataType]) -> Optional[str]:
+        """Encode a data type to its Ossie wire value."""
+        return None if value is None else value.value
+
+    @staticmethod
+    def deserialize(value: Optional[str]) -> Optional[DataType]:
+        """Decode a data type from its Ossie wire value."""
+        if value is None:
+            return None
+        Precondition.check_argument(
+            isinstance(value, str),
+            f"DataType must be encoded as a string, but found {value}",
+        )
+        try:
+            return DataType(value)
+        except ValueError as error:
+            raise IllegalArgumentException(
+                f"Unknown Semantic Model data type: {value}"
+            ) from error
diff --git 
a/clients/client-python/gravitino/dto/semantic/json_serdes/semantic_json_mixin.py
 
b/clients/client-python/gravitino/dto/semantic/json_serdes/semantic_json_mixin.py
new file mode 100644
index 0000000000..72658a66a2
--- /dev/null
+++ 
b/clients/client-python/gravitino/dto/semantic/json_serdes/semantic_json_mixin.py
@@ -0,0 +1,74 @@
+# 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.
+
+"""Lossless JSON text conversion for Semantic Model DTOs."""
+
+import json
+from decimal import Decimal
+from typing import Any
+
+import simplejson
+from dataclasses_json import DataClassJsonMixin
+
+
+class SemanticJsonMixin(DataClassJsonMixin):
+    """JSON text conversion preserving numbers in semantic AI-context 
properties.
+
+    Dict conversion leaves Decimal values intact. The standard dataclasses-json
+    encoder turns them into strings, so use a decimal-aware encoder at the JSON
+    boundary. Decode fractional numbers as Decimal before DTO conversion to 
avoid
+    rounding nested additional properties through binary floats.
+    """
+
+    def to_json(self, **kwargs) -> str:
+        """Serialize to JSON, emitting Decimal values as exact JSON numbers.
+
+        Keyword arguments are forwarded to simplejson.dumps, including 
formatting
+        options such as indent and sort_keys.
+        """
+        return simplejson.dumps(
+            _preserve_decimal_type(self.to_dict()), use_decimal=True, **kwargs
+        )
+
+    @classmethod
+    def from_json(cls, s, *, parse_float=Decimal, infer_missing=False, 
**kwargs):
+        """Deserialize JSON with lossless fractional numbers by default.
+
+        Other decoding options are forwarded to json.loads. An explicit
+        parse_float overrides the default Decimal conversion.
+        """
+        return cls.from_dict(
+            json.loads(s, parse_float=parse_float, **kwargs),
+            infer_missing=infer_missing,
+        )
+
+
+def _preserve_decimal_type(value: Any) -> Any:
+    """Ensure Decimal values produce decimal or exponent JSON tokens.
+
+    Decimal("1") normally emits an integer token, which decodes as int. Append 
a
+    fractional zero only for exponent-zero decimals. Constructing from text is
+    exact regardless of the active decimal context, unlike quantize or 
arithmetic.
+    Work on a copy so the DTO's original values and scale remain unchanged.
+    """
+    if isinstance(value, Decimal) and value.as_tuple().exponent == 0:
+        return Decimal(f"{value}.0")
+    if isinstance(value, dict):
+        return {key: _preserve_decimal_type(item) for key, item in 
value.items()}
+    if isinstance(value, (list, tuple)):
+        return [_preserve_decimal_type(item) for item in value]
+    return value
diff --git a/clients/client-python/gravitino/dto/semantic/metric_dto.py 
b/clients/client-python/gravitino/dto/semantic/metric_dto.py
new file mode 100644
index 0000000000..bb9d2e92b3
--- /dev/null
+++ b/clients/client-python/gravitino/dto/semantic/metric_dto.py
@@ -0,0 +1,129 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from dataclasses import dataclass, field
+from typing import Optional
+
+from dataclasses_json import config
+
+from gravitino.api.semantic.data_type import DataType
+from gravitino.api.semantic.metric import Metric
+from gravitino.dto.semantic.json_serdes.semantic_json_mixin import 
SemanticJsonMixin
+from gravitino.dto.semantic.ai_context_dto import AIContextDTO
+from gravitino.dto.semantic.custom_extension_dto import CustomExtensionDTO
+from gravitino.dto.semantic.expression_dto import ExpressionDTO
+from gravitino.dto.semantic.json_serdes.ai_context_serdes import 
AIContextSerdes
+from gravitino.dto.semantic.json_serdes.data_type_serdes import DataTypeSerdes
+from gravitino.dto.semantic.semantic_dto_utils import convert_list, is_none
+from gravitino.utils.precondition import Precondition
+
+
+@dataclass
+class MetricDTO(SemanticJsonMixin):
+    """Represents a Semantic Model metric DTO."""
+
+    _name: Optional[str] = field(
+        default=None, metadata=config(field_name="name", exclude=is_none)
+    )
+    _expression: Optional[ExpressionDTO] = field(
+        default=None, metadata=config(field_name="expression", exclude=is_none)
+    )
+    _description: Optional[str] = field(
+        default=None,
+        metadata=config(field_name="description", exclude=is_none),
+    )
+    _datatype: Optional[DataType] = field(
+        default=None,
+        metadata=config(
+            field_name="datatype",
+            encoder=DataTypeSerdes.serialize,
+            decoder=DataTypeSerdes.deserialize,
+            exclude=is_none,
+        ),
+    )
+    _ai_context: Optional[AIContextDTO] = field(
+        default=None,
+        metadata=config(
+            field_name="aiContext",
+            encoder=AIContextSerdes.serialize,
+            decoder=AIContextSerdes.deserialize,
+            exclude=is_none,
+        ),
+    )
+    _custom_extensions: Optional[list[CustomExtensionDTO]] = field(
+        default=None,
+        metadata=config(field_name="customExtensions", exclude=is_none),
+    )
+
+    def name(self) -> Optional[str]:
+        """Returns the metric name."""
+        return self._name
+
+    def expression(self) -> Optional[ExpressionDTO]:
+        """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[AIContextDTO]:
+        """Returns the AI context, or `None` if it is not set."""
+        return self._ai_context
+
+    def custom_extensions(self) -> Optional[list[CustomExtensionDTO]]:
+        """Returns the custom extensions, or `None` if they are not set."""
+        return self._custom_extensions
+
+    @staticmethod
+    def from_metric(metric: Metric) -> "MetricDTO":
+        """Convert a metric to its DTO."""
+        ai_context = metric.ai_context()
+        return MetricDTO(
+            _name=metric.name(),
+            _expression=ExpressionDTO.from_expression(metric.expression()),
+            _description=metric.description(),
+            _datatype=metric.datatype(),
+            _ai_context=(
+                None if ai_context is None else 
AIContextDTO.from_ai_context(ai_context)
+            ),
+            _custom_extensions=convert_list(
+                metric.custom_extensions(), 
CustomExtensionDTO.from_custom_extension
+            ),
+        )
+
+    def to_metric(self) -> Metric:
+        """Convert this DTO to a metric."""
+        Precondition.check_argument(
+            self._expression is not None, "expression must not be null"
+        )
+        return Metric(
+            name=self._name,
+            expression=self._expression.to_expression(),
+            description=self._description,
+            datatype=self._datatype,
+            ai_context=(
+                None if self._ai_context is None else 
self._ai_context.to_ai_context()
+            ),
+            custom_extensions=convert_list(
+                self._custom_extensions, CustomExtensionDTO.to_custom_extension
+            ),
+        )
diff --git a/clients/client-python/gravitino/dto/semantic/relationship_dto.py 
b/clients/client-python/gravitino/dto/semantic/relationship_dto.py
new file mode 100644
index 0000000000..80cb0928b3
--- /dev/null
+++ b/clients/client-python/gravitino/dto/semantic/relationship_dto.py
@@ -0,0 +1,127 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from dataclasses import dataclass, field
+from typing import Optional
+
+from dataclasses_json import config
+
+from gravitino.api.semantic.relationship import Relationship
+from gravitino.dto.semantic.json_serdes.semantic_json_mixin import 
SemanticJsonMixin
+from gravitino.dto.semantic.ai_context_dto import AIContextDTO
+from gravitino.dto.semantic.custom_extension_dto import CustomExtensionDTO
+from gravitino.dto.semantic.json_serdes.ai_context_serdes import 
AIContextSerdes
+from gravitino.dto.semantic.semantic_dto_utils import convert_list, is_none
+
+
+@dataclass
+class RelationshipDTO(
+    SemanticJsonMixin
+):  # pylint: disable=too-many-instance-attributes
+    """Represents a Semantic Model relationship DTO."""
+
+    _name: Optional[str] = field(
+        default=None, metadata=config(field_name="name", exclude=is_none)
+    )
+    _from_dataset: Optional[str] = field(
+        default=None, metadata=config(field_name="from", exclude=is_none)
+    )
+    _to_dataset: Optional[str] = field(
+        default=None, metadata=config(field_name="to", exclude=is_none)
+    )
+    _from_columns: Optional[list[str]] = field(
+        default=None, metadata=config(field_name="fromColumns", 
exclude=is_none)
+    )
+    _to_columns: Optional[list[str]] = field(
+        default=None, metadata=config(field_name="toColumns", exclude=is_none)
+    )
+    _ai_context: Optional[AIContextDTO] = field(
+        default=None,
+        metadata=config(
+            field_name="aiContext",
+            encoder=AIContextSerdes.serialize,
+            decoder=AIContextSerdes.deserialize,
+            exclude=is_none,
+        ),
+    )
+    _custom_extensions: Optional[list[CustomExtensionDTO]] = field(
+        default=None,
+        metadata=config(field_name="customExtensions", exclude=is_none),
+    )
+
+    def name(self) -> Optional[str]:
+        """Returns the relationship name."""
+        return self._name
+
+    def from_dataset(self) -> Optional[str]:
+        """Returns the name of the dataset the relationship joins from."""
+        return self._from_dataset
+
+    def to_dataset(self) -> Optional[str]:
+        """Returns the name of the dataset the relationship joins to."""
+        return self._to_dataset
+
+    def from_columns(self) -> Optional[list[str]]:
+        """Returns the joined columns exposed by the source dataset."""
+        return self._from_columns
+
+    def to_columns(self) -> Optional[list[str]]:
+        """Returns the joined columns exposed by the target dataset."""
+        return self._to_columns
+
+    def ai_context(self) -> Optional[AIContextDTO]:
+        """Returns the AI context, or `None` if it is not set."""
+        return self._ai_context
+
+    def custom_extensions(self) -> Optional[list[CustomExtensionDTO]]:
+        """Returns the custom extensions, or `None` if they are not set."""
+        return self._custom_extensions
+
+    @staticmethod
+    def from_relationship(relationship: Relationship) -> "RelationshipDTO":
+        """Convert a relationship to its DTO."""
+        ai_context = relationship.ai_context()
+        return RelationshipDTO(
+            _name=relationship.name(),
+            _from_dataset=relationship.from_dataset(),
+            _to_dataset=relationship.to_dataset(),
+            _from_columns=relationship.from_columns(),
+            _to_columns=relationship.to_columns(),
+            _ai_context=(
+                None if ai_context is None else 
AIContextDTO.from_ai_context(ai_context)
+            ),
+            _custom_extensions=convert_list(
+                relationship.custom_extensions(),
+                CustomExtensionDTO.from_custom_extension,
+            ),
+        )
+
+    def to_relationship(self) -> Relationship:
+        """Convert this DTO to a relationship."""
+        return Relationship(
+            name=self._name,
+            from_dataset=self._from_dataset,
+            to_dataset=self._to_dataset,
+            from_columns=self._from_columns,
+            to_columns=self._to_columns,
+            ai_context=(
+                None if self._ai_context is None else 
self._ai_context.to_ai_context()
+            ),
+            custom_extensions=convert_list(
+                self._custom_extensions, CustomExtensionDTO.to_custom_extension
+            ),
+        )
diff --git a/clients/client-python/gravitino/dto/semantic/semantic_dto_utils.py 
b/clients/client-python/gravitino/dto/semantic/semantic_dto_utils.py
new file mode 100644
index 0000000000..ca21677c7e
--- /dev/null
+++ b/clients/client-python/gravitino/dto/semantic/semantic_dto_utils.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.
+
+"""Shared conversion helpers for the Semantic Model DTOs."""
+
+from typing import Any, Callable, Optional
+
+
+def is_none(value: Any) -> bool:
+    """Predicate that omits unset optional fields during serialization.
+
+    Args:
+        value (Any): The field value.
+
+    Returns:
+        bool: `True` if the field is unset and must not be serialized.
+    """
+    return value is None
+
+
+def convert_list(values: Optional[list], converter: Callable) -> 
Optional[list]:
+    """Convert an optional list, preserving `None` for the list and its 
elements.
+
+    Args:
+        values (list, optional): The list to convert, `None` is allowed.
+        converter (Callable): The element converter.
+
+    Returns:
+        list, optional: The converted list, or `None` if the input is `None`.
+    """
+    if values is None:
+        return None
+    return [None if value is None else converter(value) for value in values]
diff --git 
a/clients/client-python/gravitino/dto/semantic/semantic_model_definition_dto.py 
b/clients/client-python/gravitino/dto/semantic/semantic_model_definition_dto.py
new file mode 100644
index 0000000000..5a75fa424a
--- /dev/null
+++ 
b/clients/client-python/gravitino/dto/semantic/semantic_model_definition_dto.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 dataclasses import dataclass, field
+from typing import Optional
+
+from dataclasses_json import config
+
+from gravitino.api.semantic.semantic_model_definition import 
SemanticModelDefinition
+from gravitino.dto.semantic.json_serdes.semantic_json_mixin import 
SemanticJsonMixin
+from gravitino.dto.semantic.ai_context_dto import AIContextDTO
+from gravitino.dto.semantic.custom_extension_dto import CustomExtensionDTO
+from gravitino.dto.semantic.dataset_dto import DatasetDTO
+from gravitino.dto.semantic.json_serdes.ai_context_serdes import 
AIContextSerdes
+from gravitino.dto.semantic.metric_dto import MetricDTO
+from gravitino.dto.semantic.relationship_dto import RelationshipDTO
+from gravitino.dto.semantic.semantic_dto_utils import convert_list, is_none
+
+
+@dataclass
+class SemanticModelDefinitionDTO(SemanticJsonMixin):
+    """Represents a Semantic Model definition DTO."""
+
+    _ai_context: Optional[AIContextDTO] = field(
+        default=None,
+        metadata=config(
+            field_name="aiContext",
+            encoder=AIContextSerdes.serialize,
+            decoder=AIContextSerdes.deserialize,
+            exclude=is_none,
+        ),
+    )
+    _datasets: Optional[list[DatasetDTO]] = field(
+        default=None, metadata=config(field_name="datasets", exclude=is_none)
+    )
+    _relationships: Optional[list[RelationshipDTO]] = field(
+        default=None,
+        metadata=config(field_name="relationships", exclude=is_none),
+    )
+    _metrics: Optional[list[MetricDTO]] = field(
+        default=None,
+        metadata=config(field_name="metrics", exclude=is_none),
+    )
+    _custom_extensions: Optional[list[CustomExtensionDTO]] = field(
+        default=None,
+        metadata=config(field_name="customExtensions", exclude=is_none),
+    )
+
+    def ai_context(self) -> Optional[AIContextDTO]:
+        """Returns the model-level AI context, or `None` if it is not set."""
+        return self._ai_context
+
+    def datasets(self) -> Optional[list[DatasetDTO]]:
+        """Returns the datasets."""
+        return self._datasets
+
+    def relationships(self) -> Optional[list[RelationshipDTO]]:
+        """Returns the relationships, or `None` if they are not set."""
+        return self._relationships
+
+    def metrics(self) -> Optional[list[MetricDTO]]:
+        """Returns the metrics, or `None` if they are not set."""
+        return self._metrics
+
+    def custom_extensions(self) -> Optional[list[CustomExtensionDTO]]:
+        """Returns the custom extensions, or `None` if they are not set."""
+        return self._custom_extensions
+
+    @staticmethod
+    def from_definition(
+        definition: SemanticModelDefinition,
+    ) -> "SemanticModelDefinitionDTO":
+        """Convert a Semantic Model definition to its DTO."""
+        ai_context = definition.ai_context()
+        return SemanticModelDefinitionDTO(
+            _ai_context=(
+                None if ai_context is None else 
AIContextDTO.from_ai_context(ai_context)
+            ),
+            _datasets=convert_list(definition.datasets(), 
DatasetDTO.from_dataset),
+            _relationships=convert_list(
+                definition.relationships(), RelationshipDTO.from_relationship
+            ),
+            _metrics=convert_list(definition.metrics(), MetricDTO.from_metric),
+            _custom_extensions=convert_list(
+                definition.custom_extensions(),
+                CustomExtensionDTO.from_custom_extension,
+            ),
+        )
+
+    def to_definition(self) -> SemanticModelDefinition:
+        """Convert this DTO to a Semantic Model definition.
+
+        Raises:
+            IllegalArgumentException: If a definition field is invalid.
+        """
+        return SemanticModelDefinition(
+            datasets=convert_list(self._datasets, DatasetDTO.to_dataset),
+            ai_context=(
+                None if self._ai_context is None else 
self._ai_context.to_ai_context()
+            ),
+            relationships=convert_list(
+                self._relationships, RelationshipDTO.to_relationship
+            ),
+            metrics=convert_list(self._metrics, MetricDTO.to_metric),
+            custom_extensions=convert_list(
+                self._custom_extensions, CustomExtensionDTO.to_custom_extension
+            ),
+        )
diff --git a/clients/client-python/gravitino/dto/semantic/semantic_model_dto.py 
b/clients/client-python/gravitino/dto/semantic/semantic_model_dto.py
new file mode 100644
index 0000000000..28301b4cf0
--- /dev/null
+++ b/clients/client-python/gravitino/dto/semantic/semantic_model_dto.py
@@ -0,0 +1,75 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from dataclasses import dataclass, field
+from typing import Optional
+
+from dataclasses_json import config
+
+from gravitino.api.semantic.semantic_model import SemanticModel
+from gravitino.api.semantic.semantic_model_definition import 
SemanticModelDefinition
+from gravitino.dto.semantic.json_serdes.semantic_json_mixin import 
SemanticJsonMixin
+from gravitino.dto.audit_dto import AuditDTO
+from gravitino.dto.semantic.semantic_model_definition_dto import (
+    SemanticModelDefinitionDTO,
+)
+from gravitino.utils.precondition import Precondition
+from gravitino.dto.semantic.semantic_dto_utils import is_none
+
+
+@dataclass
+class SemanticModelDTO(SemanticModel, SemanticJsonMixin):
+    """Represents a schema-scoped Semantic Model DTO."""
+
+    _name: Optional[str] = field(
+        default=None, metadata=config(field_name="name", exclude=is_none)
+    )
+    _comment: Optional[str] = field(
+        default=None,
+        metadata=config(field_name="comment", exclude=is_none),
+    )
+    _definition: Optional[SemanticModelDefinitionDTO] = field(
+        default=None, metadata=config(field_name="definition", exclude=is_none)
+    )
+    _properties: Optional[dict[str, str]] = field(
+        default=None, metadata=config(field_name="properties", exclude=is_none)
+    )
+    _audit: Optional[AuditDTO] = field(
+        default=None, metadata=config(field_name="audit", exclude=is_none)
+    )
+
+    def name(self) -> Optional[str]:
+        return self._name
+
+    def comment(self) -> Optional[str]:
+        return self._comment
+
+    def definition(self) -> SemanticModelDefinition:
+        Precondition.check_argument(
+            self._definition is not None, "definition must not be null"
+        )
+        return self._definition.to_definition()
+
+    def definition_dto(self) -> Optional[SemanticModelDefinitionDTO]:
+        """Returns the definition DTO without converting it to the API type."""
+        return self._definition
+
+    def properties(self) -> dict[str, str]:
+        return self._properties or {}
+
+    def audit_info(self) -> Optional[AuditDTO]:
+        return self._audit
diff --git a/clients/client-python/requirements.txt 
b/clients/client-python/requirements.txt
index 57353994fe..8f586463d4 100644
--- a/clients/client-python/requirements.txt
+++ b/clients/client-python/requirements.txt
@@ -19,6 +19,7 @@
 # the tools to publish the python client to Pypi
 requests==2.32.5
 dataclasses-json==0.6.7
+simplejson==3.20.1
 readerwriterlock==1.0.9
 fsspec==2026.2.0
 pyarrow==23.0.1
diff --git a/clients/client-python/requirements.txt 
b/clients/client-python/tests/unittests/dto/semantic/__init__.py
similarity index 69%
copy from clients/client-python/requirements.txt
copy to clients/client-python/tests/unittests/dto/semantic/__init__.py
index 57353994fe..13a83393a9 100644
--- a/clients/client-python/requirements.txt
+++ b/clients/client-python/tests/unittests/dto/semantic/__init__.py
@@ -6,7 +6,7 @@
 # "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
+#   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
@@ -14,19 +14,3 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-
-
-# the tools to publish the python client to Pypi
-requests==2.32.5
-dataclasses-json==0.6.7
-readerwriterlock==1.0.9
-fsspec==2026.2.0
-pyarrow==23.0.1
-cachetools==7.1.4
-gcsfs==2026.2.0
-s3fs==2026.2.0
-ossfs==2025.5.0
-adlfs==2026.2.0
-black==26.3.1
-flake8==7.0.0
-pre-commit==3.5.0
diff --git 
a/clients/client-python/tests/unittests/dto/semantic/test_semantic_model_definition_dto.py
 
b/clients/client-python/tests/unittests/dto/semantic/test_semantic_model_definition_dto.py
new file mode 100644
index 0000000000..7ca04657e2
--- /dev/null
+++ 
b/clients/client-python/tests/unittests/dto/semantic/test_semantic_model_definition_dto.py
@@ -0,0 +1,247 @@
+# 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.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.api.semantic.semantic_model_definition import 
SemanticModelDefinition
+from gravitino.dto.semantic.semantic_model_definition_dto import (
+    SemanticModelDefinitionDTO,
+)
+from gravitino.name_identifier import NameIdentifier
+
+_MINIMAL_DEFINITION_JSON = {
+    "datasets": [
+        {"name": "orders", "source": {"namespace": ["sales", "mart"], "name": 
"orders"}}
+    ]
+}
+
+
+def _minimal_definition() -> SemanticModelDefinition:
+    return SemanticModelDefinition(
+        datasets=[Dataset("orders", NameIdentifier.of("sales", "mart", 
"orders"))]
+    )
+
+
+def _complete_definition() -> SemanticModelDefinition:
+    order_amount = Field(
+        name="order_amount",
+        expression=Expression([DialectExpression(Dialects.ANSI_SQL, 
"order_amount")]),
+        dimension=Dimension(is_time=False),
+        label="Order amount",
+        description="The order amount",
+        datatype=DataType.DECIMAL,
+        ai_context=AIContext.of("Amount charged for the order"),
+        custom_extensions=[CustomExtension("acme", '{"unit": "usd"}')],
+    )
+    orders = 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(
+            AIContextObject(
+                instructions="Use certified metrics only",
+                synonyms=["sales"],
+                examples=["total revenue by month"],
+                additional_properties={"audience": "finance"},
+            )
+        ),
+        fields=[order_amount],
+        custom_extensions=[CustomExtension("acme", "{}")],
+    )
+    customers = Dataset("customers", NameIdentifier.of("sales", "mart", 
"customers"))
+    relationship = Relationship(
+        name="orders_to_customers",
+        from_dataset="orders",
+        to_dataset="customers",
+        from_columns=["customer_id"],
+        to_columns=["id"],
+        ai_context=AIContext.of("Order to customer join"),
+        custom_extensions=[CustomExtension("acme", "{}")],
+    )
+    total_revenue = Metric(
+        name="total_revenue",
+        expression=Expression(
+            [
+                DialectExpression(Dialects.ANSI_SQL, 
"SUM(orders.order_amount)"),
+                DialectExpression(Dialects.SNOWFLAKE, 
"SUM(ORDERS.ORDER_AMOUNT)"),
+            ]
+        ),
+        description="Total revenue across all orders",
+        datatype=DataType.DECIMAL,
+        ai_context=AIContext.of("Certified revenue metric"),
+        custom_extensions=[CustomExtension("acme", "{}")],
+    )
+    return SemanticModelDefinition(
+        datasets=[orders, customers],
+        ai_context=AIContext.of("Governed sales definitions"),
+        relationships=[relationship],
+        metrics=[total_revenue],
+        custom_extensions=[CustomExtension("acme", '{"owner": "finance"}')],
+    )
+
+
+class TestSemanticModelDefinitionDTO(unittest.TestCase):
+    def test_minimal_definition_serializes_without_optional_fields(self):
+        dto = SemanticModelDefinitionDTO.from_definition(_minimal_definition())
+
+        self.assertEqual(_MINIMAL_DEFINITION_JSON, dto.to_dict())
+
+    def test_minimal_definition_deserializes(self):
+        dto = SemanticModelDefinitionDTO.from_dict(_MINIMAL_DEFINITION_JSON)
+
+        self.assertEqual(_minimal_definition(), dto.to_definition())
+
+    def test_complete_definition_round_trips_through_json(self):
+        definition = _complete_definition()
+
+        dto = SemanticModelDefinitionDTO.from_definition(definition)
+        restored = SemanticModelDefinitionDTO.from_json(dto.to_json())
+
+        self.assertEqual(definition, restored.to_definition())
+
+    def test_ai_context_string_serializes_as_a_bare_string(self):
+        definition = SemanticModelDefinition(
+            datasets=_minimal_definition().datasets(),
+            ai_context=AIContext.of("Governed sales definitions"),
+        )
+
+        serialized = 
SemanticModelDefinitionDTO.from_definition(definition).to_dict()
+
+        self.assertEqual("Governed sales definitions", serialized["aiContext"])
+
+    def test_ai_context_object_flattens_additional_properties(self):
+        ai_context = AIContext.of(
+            AIContextObject(
+                instructions="Use certified metrics only",
+                synonyms=["sales"],
+                additional_properties={"audience": "finance", "priority": 1},
+            )
+        )
+        definition = SemanticModelDefinition(
+            datasets=_minimal_definition().datasets(), ai_context=ai_context
+        )
+
+        serialized = 
SemanticModelDefinitionDTO.from_definition(definition).to_dict()
+
+        self.assertEqual(
+            {
+                "instructions": "Use certified metrics only",
+                "synonyms": ["sales"],
+                "audience": "finance",
+                "priority": 1,
+            },
+            serialized["aiContext"],
+        )
+
+    def test_ai_context_object_round_trips_additional_properties(self):
+        ai_context = AIContext.of(
+            AIContextObject(
+                additional_properties={
+                    "audience": "finance",
+                    "thresholds": {"warn": 10, "limits": [1, 2.5, True, None]},
+                }
+            )
+        )
+        definition = SemanticModelDefinition(
+            datasets=_minimal_definition().datasets(), ai_context=ai_context
+        )
+
+        dto = SemanticModelDefinitionDTO.from_definition(definition)
+        restored = SemanticModelDefinitionDTO.from_json(dto.to_json())
+
+        self.assertEqual(definition, restored.to_definition())
+
+    def test_datatype_uses_ossie_wire_values(self):
+        definition = _complete_definition()
+
+        serialized = 
SemanticModelDefinitionDTO.from_definition(definition).to_dict()
+
+        self.assertEqual("Decimal", 
serialized["datasets"][0]["fields"][0]["datatype"])
+        self.assertEqual("Decimal", serialized["metrics"][0]["datatype"])
+
+    def test_relationship_uses_ossie_field_names(self):
+        definition = _complete_definition()
+
+        serialized = 
SemanticModelDefinitionDTO.from_definition(definition).to_dict()
+        relationship = serialized["relationships"][0]
+
+        self.assertEqual("orders", relationship["from"])
+        self.assertEqual("customers", relationship["to"])
+        self.assertEqual(["customer_id"], relationship["fromColumns"])
+        self.assertEqual(["id"], relationship["toColumns"])
+
+    def test_dataset_source_uses_name_identifier_encoding(self):
+        definition = _complete_definition()
+
+        serialized = 
SemanticModelDefinitionDTO.from_definition(definition).to_dict()
+
+        self.assertEqual(
+            {"namespace": ["sales", "mart"], "name": "orders"},
+            serialized["datasets"][0]["source"],
+        )
+
+    def test_to_definition_rejects_an_empty_definition(self):
+        dto = SemanticModelDefinitionDTO.from_dict({"datasets": []})
+
+        with self.assertRaisesRegex(ValueError, "datasets must not be null or 
empty"):
+            dto.to_definition()
+
+    def test_to_definition_rejects_an_unknown_datatype(self):
+        with self.assertRaisesRegex(
+            ValueError, "Unknown Semantic Model data type: Money"
+        ):
+            SemanticModelDefinitionDTO.from_dict(
+                {
+                    "datasets": [
+                        {
+                            "name": "orders",
+                            "source": {
+                                "namespace": ["sales", "mart"],
+                                "name": "orders",
+                            },
+                            "fields": [
+                                {
+                                    "name": "order_amount",
+                                    "expression": {
+                                        "dialects": [
+                                            {
+                                                "dialect": "ANSI_SQL",
+                                                "expression": "order_amount",
+                                            }
+                                        ]
+                                    },
+                                    "datatype": "Money",
+                                }
+                            ],
+                        }
+                    ]
+                }
+            )
diff --git 
a/clients/client-python/tests/unittests/dto/semantic/test_semantic_model_dto.py 
b/clients/client-python/tests/unittests/dto/semantic/test_semantic_model_dto.py
new file mode 100644
index 0000000000..6f8106f4e4
--- /dev/null
+++ 
b/clients/client-python/tests/unittests/dto/semantic/test_semantic_model_dto.py
@@ -0,0 +1,110 @@
+# 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_definition import 
SemanticModelDefinition
+from gravitino.dto.audit_dto import AuditDTO
+from gravitino.dto.semantic.semantic_model_definition_dto import (
+    SemanticModelDefinitionDTO,
+)
+from gravitino.dto.semantic.semantic_model_dto import SemanticModelDTO
+from gravitino.name_identifier import NameIdentifier
+
+_SEMANTIC_MODEL_JSON = {
+    "name": "sales_model",
+    "comment": "Governed sales definitions",
+    "definition": {
+        "datasets": [
+            {
+                "name": "orders",
+                "source": {"namespace": ["sales", "mart"], "name": "orders"},
+            }
+        ]
+    },
+    "properties": {"owner": "finance"},
+    "audit": {"creator": "gravitino"},
+}
+
+
+def _definition() -> SemanticModelDefinition:
+    return SemanticModelDefinition(
+        datasets=[Dataset("orders", NameIdentifier.of("sales", "mart", 
"orders"))]
+    )
+
+
+class TestSemanticModelDTO(unittest.TestCase):
+    def test_deserializes_a_semantic_model(self):
+        dto = SemanticModelDTO.from_dict(_SEMANTIC_MODEL_JSON)
+
+        self.assertEqual("sales_model", dto.name())
+        self.assertEqual("Governed sales definitions", dto.comment())
+        self.assertEqual(_definition(), dto.definition())
+        self.assertEqual({"owner": "finance"}, dto.properties())
+        self.assertEqual("gravitino", dto.audit_info().creator())
+
+    def test_serializes_a_semantic_model(self):
+        dto = SemanticModelDTO(
+            _name="sales_model",
+            _comment="Governed sales definitions",
+            
_definition=SemanticModelDefinitionDTO.from_definition(_definition()),
+            _properties={"owner": "finance"},
+            _audit=AuditDTO(_creator="gravitino"),
+        )
+
+        serialized = dto.to_dict()
+
+        self.assertEqual("sales_model", serialized["name"])
+        self.assertEqual("Governed sales definitions", serialized["comment"])
+        self.assertEqual(_SEMANTIC_MODEL_JSON["definition"], 
serialized["definition"])
+        self.assertEqual({"owner": "finance"}, serialized["properties"])
+        self.assertEqual("gravitino", serialized["audit"]["creator"])
+
+    def test_round_trips_through_json(self):
+        dto = SemanticModelDTO.from_dict(_SEMANTIC_MODEL_JSON)
+
+        restored = SemanticModelDTO.from_json(dto.to_json())
+
+        self.assertEqual(dto, restored)
+
+    def test_omits_an_unset_comment(self):
+        dto = SemanticModelDTO(
+            _name="sales_model",
+            
_definition=SemanticModelDefinitionDTO.from_definition(_definition()),
+            _properties={},
+            _audit=AuditDTO(_creator="gravitino"),
+        )
+
+        self.assertNotIn("comment", dto.to_dict())
+        self.assertIsNone(dto.comment())
+
+    def test_properties_default_to_an_empty_mapping(self):
+        dto = SemanticModelDTO(
+            _name="sales_model",
+            
_definition=SemanticModelDefinitionDTO.from_definition(_definition()),
+            _audit=AuditDTO(_creator="gravitino"),
+        )
+
+        self.assertEqual({}, dto.properties())
+
+    def test_definition_requires_a_definition(self):
+        dto = SemanticModelDTO(_name="sales_model", 
_audit=AuditDTO(_creator="a"))
+
+        self.assertIsNone(dto.definition_dto())
+        with self.assertRaisesRegex(ValueError, "definition must not be null"):
+            dto.definition()
diff --git 
a/clients/client-python/tests/unittests/dto/semantic/test_semantic_wire_compatibility.py
 
b/clients/client-python/tests/unittests/dto/semantic/test_semantic_wire_compatibility.py
new file mode 100644
index 0000000000..89b198e61d
--- /dev/null
+++ 
b/clients/client-python/tests/unittests/dto/semantic/test_semantic_wire_compatibility.py
@@ -0,0 +1,307 @@
+# 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.semantic_model_definition import 
SemanticModelDefinition
+from gravitino.dto.semantic.dataset_dto import DatasetDTO
+from gravitino.dto.semantic.field_dto import FieldDTO
+from gravitino.dto.semantic.metric_dto import MetricDTO
+from gravitino.dto.semantic.relationship_dto import RelationshipDTO
+from gravitino.dto.semantic.ai_context_object_dto import AIContextObjectDTO
+from gravitino.dto.semantic.json_serdes.ai_context_serdes import 
AIContextSerdes
+from gravitino.dto.semantic.semantic_model_definition_dto import (
+    SemanticModelDefinitionDTO,
+)
+from gravitino.dto.semantic.semantic_model_dto import SemanticModelDTO
+from tests.unittests.dto.semantic.test_semantic_model_definition_dto import (
+    _complete_definition,
+)
+
+
+class TestSemanticWireCompatibility(unittest.TestCase):
+    def test_java_wire_names(self):
+        # Mirror the camelCase contract asserted by Java's
+        # 
TestSemanticModelDefinitionDTO.testDefinitionConversionAndJsonRoundTrip.
+        wire = {
+            "aiContext": "Governed sales definitions",
+            "datasets": [
+                {
+                    "name": "orders",
+                    "source": {"namespace": ["sales", "mart"], "name": 
"orders"},
+                    "primaryKey": ["order_id"],
+                    "uniqueKeys": [["order_id"], ["customer_id", 
"order_date"]],
+                    "description": "Order facts",
+                    "aiContext": {
+                        "instructions": "Use certified metrics only",
+                        "synonyms": ["sales"],
+                        "examples": ["total revenue by month"],
+                        "audience": "finance",
+                    },
+                    "fields": [
+                        {
+                            "name": "order_amount",
+                            "expression": {
+                                "dialects": [
+                                    {
+                                        "dialect": "ANSI_SQL",
+                                        "expression": "order_amount",
+                                    }
+                                ]
+                            },
+                            "dimension": {"isTime": False},
+                            "label": "Order amount",
+                            "description": "The order amount",
+                            "datatype": "Decimal",
+                            "aiContext": "Amount charged for the order",
+                            "customExtensions": [
+                                {"vendorName": "acme", "data": '{"unit": 
"usd"}'}
+                            ],
+                        }
+                    ],
+                    "customExtensions": [{"vendorName": "acme", "data": "{}"}],
+                },
+                {
+                    "name": "customers",
+                    "source": {"namespace": ["sales", "mart"], "name": 
"customers"},
+                },
+            ],
+            "relationships": [
+                {
+                    "name": "orders_to_customers",
+                    "from": "orders",
+                    "to": "customers",
+                    "fromColumns": ["customer_id"],
+                    "toColumns": ["id"],
+                    "aiContext": "Order to customer join",
+                    "customExtensions": [{"vendorName": "acme", "data": "{}"}],
+                }
+            ],
+            "metrics": [
+                {
+                    "name": "total_revenue",
+                    "expression": {
+                        "dialects": [
+                            {
+                                "dialect": "ANSI_SQL",
+                                "expression": "SUM(orders.order_amount)",
+                            },
+                            {
+                                "dialect": "SNOWFLAKE",
+                                "expression": "SUM(ORDERS.ORDER_AMOUNT)",
+                            },
+                        ]
+                    },
+                    "description": "Total revenue across all orders",
+                    "datatype": "Decimal",
+                    "aiContext": "Certified revenue metric",
+                    "customExtensions": [{"vendorName": "acme", "data": "{}"}],
+                }
+            ],
+            "customExtensions": [
+                {"vendorName": "acme", "data": '{"owner": "finance"}'}
+            ],
+        }
+        definition = _complete_definition()
+        restored = SemanticModelDefinitionDTO.from_json(json.dumps(wire))
+        self.assertEqual(definition, restored.to_definition())
+        self.assertEqual(
+            wire,
+            json.loads(
+                
SemanticModelDefinitionDTO.from_definition(definition).to_json()
+            ),
+        )
+
+    def test_decimal_json_numbers(self):
+        # Same precise JSON number used by Java's AI-context serde regression.
+        number = "0.123456789012345678901234567890"
+        context_json = (
+            '{"nested": [{"value": NUMBER}], '
+            '"integer": 123456789012345678901234567890, '
+            '"flag": true, "empty": null}'
+        )
+        for dto_type in (
+            DatasetDTO,
+            FieldDTO,
+            MetricDTO,
+            RelationshipDTO,
+            SemanticModelDefinitionDTO,
+            SemanticModelDTO,
+        ):
+            with self.subTest(dto=dto_type), localcontext() as context:
+                context.prec = 6
+                wire = '{"aiContext": ' + context_json.replace("NUMBER", 
number) + "}"
+                if dto_type is SemanticModelDTO:
+                    wire = '{"name": "sales", "definition": ' + wire + "}"
+                dto = dto_type.from_json(wire)
+                self.assertEqual(
+                    json.loads(wire, parse_float=Decimal),
+                    json.loads(dto.to_json(), parse_float=Decimal),
+                )
+
+    def test_decimal_encoding_from_api(self):
+        value = Decimal("0.123456789012345678901234567890")
+        definition = SemanticModelDefinition(
+            datasets=_complete_definition().datasets(),
+            ai_context=AIContext.of(
+                AIContextObject(additional_properties={"nested": [{"value": 
value}]})
+            ),
+        )
+        dto = SemanticModelDefinitionDTO.from_definition(definition)
+        wire = dto.to_json()
+        self.assertEqual(
+            value,
+            json.loads(wire, 
parse_float=Decimal)["aiContext"]["nested"][0]["value"],
+        )
+        self.assertEqual(
+            dto.to_definition(),
+            SemanticModelDefinitionDTO.from_json(wire).to_definition(),
+        )
+
+    def test_explicit_null_optional_properties(self):
+        value = {
+            "instructions": None,
+            "synonyms": None,
+            "examples": None,
+            "unknown": None,
+        }
+        context = AIContextSerdes.deserialize(value)
+        self.assertEqual({"unknown": None}, AIContextSerdes.serialize(context))
+        for name in ("synonyms", "examples"):
+            with self.subTest(name=name):
+                self.assertEqual(
+                    {name: []},
+                    
AIContextSerdes.serialize(AIContextSerdes.deserialize({name: []})),
+                )
+                with self.assertRaises(ValueError):
+                    AIContextSerdes.deserialize({name: [None]})
+        with self.assertRaises(ValueError):
+            AIContextSerdes.deserialize({"instructions": 42})
+
+    def test_nested_value_equality(self):
+        for left, right, equal in (
+            (True, 1, False),
+            (1, 1.0, False),
+            (0.1, Decimal("0.1"), True),
+        ):
+            with self.subTest(left=left, right=right):
+                first = AIContextObjectDTO(
+                    additional_properties={"nested": [{"value": left}]}
+                )
+                second = AIContextObjectDTO(
+                    additional_properties={"nested": [{"value": right}]}
+                )
+                self.assertEqual(equal, first == second)
+                self.assertEqual(
+                    first.to_ai_context_object() == 
second.to_ai_context_object(),
+                    first == second,
+                )
+                if equal:
+                    self.assertEqual(hash(first), hash(second))
+                    self.assertEqual("found", {first: "found"}[second])
+                definitions = [
+                    SemanticModelDefinitionDTO(
+                        _ai_context=AIContextSerdes.deserialize(
+                            {"nested": [{"value": value}]}
+                        )
+                    )
+                    for value in (left, right)
+                ]
+                self.assertEqual(equal, definitions[0] == definitions[1])
+                self.assertEqual(
+                    equal,
+                    SemanticModelDTO(_definition=definitions[0])
+                    == SemanticModelDTO(_definition=definitions[1]),
+                )
+
+    def test_json_formatting_and_decoder_options(self):
+        dto = SemanticModelDefinitionDTO.from_dict(
+            {"aiContext": {"value": Decimal("1.25")}}
+        )
+        wire = dto.to_json(indent=2, sort_keys=True, ensure_ascii=False)
+        self.assertIn("\n", wire)
+        restored = SemanticModelDefinitionDTO.from_json(
+            wire.encode(), parse_float=float
+        )
+        self.assertIsInstance(
+            restored.ai_context().object().additional_properties()["value"], 
float
+        )
+        self.assertEqual(dto, restored)
+
+    def test_whole_decimals_keep_their_type_through_json(self):
+        for text in (
+            "1",
+            "0",
+            "-1",
+            "-0",
+            "123456789012345678901234567890",
+            "1E+30",
+            "1.00",
+        ):
+            with self.subTest(value=text), localcontext() as context:
+                context.prec = 6
+                value = Decimal(text)
+                definition = SemanticModelDefinition(
+                    datasets=_complete_definition().datasets(),
+                    ai_context=AIContext.of(
+                        AIContextObject(
+                            additional_properties={
+                                "value": value,
+                                "nested": [
+                                    {"value": value, "integer": 1, "flag": 
True}
+                                ],
+                            }
+                        )
+                    ),
+                )
+                dto = SemanticModelDefinitionDTO.from_definition(definition)
+                for original in (dto, SemanticModelDTO(_name="sales", 
_definition=dto)):
+                    wire = original.to_json()
+                    restored = type(original).from_json(wire)
+                    restored_definition = (
+                        restored.definition()
+                        if isinstance(restored, SemanticModelDTO)
+                        else restored.to_definition()
+                    )
+                    properties = (
+                        restored_definition.ai_context()
+                        .object()
+                        .additional_properties()
+                    )
+                    self.assertIsInstance(properties["value"], Decimal)
+                    self.assertIsInstance(properties["nested"][0]["value"], 
Decimal)
+                    self.assertIs(type(properties["nested"][0]["integer"]), 
int)
+                    self.assertIs(type(properties["nested"][0]["flag"]), bool)
+                    self.assertEqual(value, properties["value"])
+                    self.assertEqual(value.is_signed(), 
properties["value"].is_signed())
+                    self.assertEqual(definition, restored_definition)
+                    self.assertEqual(hash(definition), 
hash(restored_definition))
+                    self.assertEqual(
+                        "found", {definition: "found"}[restored_definition]
+                    )
+                    self.assertEqual(original, restored)
+                self.assertEqual(
+                    value.as_tuple(),
+                    dto.ai_context()
+                    .object()
+                    .additional_properties()["value"]
+                    .as_tuple(),
+                )

Reply via email to