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(),
+ )