You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@ossie.apache.org by jb...@apache.org on 2026/10/10 15:58:57 UTC
(ossie) branch main updated: fix(python): reject unknown keys on nested models, as ossie validate does (#552)
This is an automated email from the ASF dual-hosted git repository.
jbonofre pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/ossie.git
The following commit(s) were added to refs/heads/main by this push:
new 9fd7b885 fix(python): reject unknown keys on nested models, as ossie validate does (#552)
9fd7b885 is described below
commit 9fd7b885b10c3681f85f0aa9e492b1980f0e34bd
Author: sunshine <13...@qq.com>
AuthorDate: Sat Oct 10 23:58:51 2026 +0800
fix(python): reject unknown keys on nested models, as ossie validate does (#552)
ossie-schema.json sets additionalProperties: false on every object but AIContext.
The SDK set extra="forbid" only on the root OssieDocument, so a mistyped key on a
nested object -- "descriptionn" on a field, "expresssion" on a metric -- passed
model_validate() and then disappeared from the serialized document, while
validation/validate.py reported both. A model the SDK round-tripped cleanly could
still fail the validator, which is the one guarantee the SDK exists to give.
The nine models whose schema node forbids extras now forbid them too:
CustomExtension, DialectExpression, Expression, Dimension, Field, Dataset,
Relationship, Metric and SemanticModel. OssieAIContextObject stays extra="allow"
because AIContext is the one open node in the schema. The test that asserted only
the document rejected extras encoded the bug as intent; it is replaced by one that
checks each nesting level and one that pins the forbidding model set against the
schema, so a node added to ossie-schema.json later cannot keep Pydantic's
extra="ignore" default unnoticed.
OssieSemanticModel.model_validate() now rejects a document's "version", which it
previously ignored. That matches the schema, where the SemanticModel node has no
version property, and the SDK already documents OssieDocument rather than
OssieSemanticModel as the type for models embedded in an ontology map.
Closes #530
---
python/src/ossie/models.py | 18 +++---
python/tests/test_models.py | 132 ++++++++++++++++++++++++++++++++++++++++++--
2 files changed, 135 insertions(+), 15 deletions(-)
diff --git a/python/src/ossie/models.py b/python/src/ossie/models.py
index 261c31f2..f23312fe 100644
--- a/python/src/ossie/models.py
+++ b/python/src/ossie/models.py
@@ -95,7 +95,7 @@ OssieAIContext = Union[str, OssieAIContextObject]
class OssieCustomExtension(BaseModel):
"""Vendor-specific metadata as a serialized JSON string."""
- model_config = ConfigDict(frozen=True)
+ model_config = ConfigDict(frozen=True, extra="forbid")
vendor_name: str
data: str
@@ -104,7 +104,7 @@ class OssieCustomExtension(BaseModel):
class OssieDialectExpression(BaseModel):
"""Expression in a specific dialect."""
- model_config = ConfigDict(frozen=True)
+ model_config = ConfigDict(frozen=True, extra="forbid")
dialect: OssieDialect
expression: str
@@ -113,7 +113,7 @@ class OssieDialectExpression(BaseModel):
class OssieExpression(BaseModel):
"""Expression definition with multi-dialect support."""
- model_config = ConfigDict(frozen=True)
+ model_config = ConfigDict(frozen=True, extra="forbid")
dialects: list[OssieDialectExpression] = Field(..., min_length=1)
@@ -121,7 +121,7 @@ class OssieExpression(BaseModel):
class OssieDimension(BaseModel):
"""Dimension metadata on a field."""
- model_config = ConfigDict(frozen=True)
+ model_config = ConfigDict(frozen=True, extra="forbid")
is_time: Optional[bool] = None
@@ -129,7 +129,7 @@ class OssieDimension(BaseModel):
class OssieField(BaseModel):
"""Row-level attribute for grouping, filtering, and metric expressions."""
- model_config = ConfigDict(frozen=True)
+ model_config = ConfigDict(frozen=True, extra="forbid")
name: str = Field(..., min_length=1)
expression: OssieExpression
@@ -157,7 +157,7 @@ class OssieField(BaseModel):
class OssieDataset(BaseModel):
"""Logical dataset representing a business entity (fact or dimension table)."""
- model_config = ConfigDict(frozen=True)
+ model_config = ConfigDict(frozen=True, extra="forbid")
name: str = Field(..., min_length=1)
source: str = Field(..., min_length=1)
@@ -172,7 +172,7 @@ class OssieDataset(BaseModel):
class OssieRelationship(BaseModel):
"""Foreign key relationship between datasets."""
- model_config = ConfigDict(frozen=True, populate_by_name=True)
+ model_config = ConfigDict(frozen=True, extra="forbid", populate_by_name=True)
name: str = Field(..., min_length=1)
from_dataset: str = Field(..., alias="from", min_length=1)
@@ -186,7 +186,7 @@ class OssieRelationship(BaseModel):
class OssieMetric(BaseModel):
"""Quantitative measure defined on business data."""
- model_config = ConfigDict(frozen=True)
+ model_config = ConfigDict(frozen=True, extra="forbid")
name: str = Field(..., min_length=1)
expression: OssieExpression
@@ -199,7 +199,7 @@ class OssieMetric(BaseModel):
class OssieSemanticModel(BaseModel):
"""Semantic model: the datasets, relationships, and metrics for one business domain."""
- model_config = ConfigDict(frozen=True)
+ model_config = ConfigDict(frozen=True, extra="forbid")
name: str = Field(..., min_length=1)
description: Optional[str] = None
diff --git a/python/tests/test_models.py b/python/tests/test_models.py
index 5bdb237a..11d1982f 100644
--- a/python/tests/test_models.py
+++ b/python/tests/test_models.py
@@ -25,6 +25,7 @@ from pydantic import BaseModel, ValidationError
from ossie import (
OssieAIContextObject,
+ OssieCustomExtension,
OssieDataset,
OssieDataType,
OssieDialect,
@@ -272,26 +273,145 @@ def test_document_is_a_semantic_model(document_data: dict) -> None:
document = OssieDocument.model_validate(document_data)
assert isinstance(document, OssieSemanticModel)
+ # `version` is document metadata, absent from the schema's SemanticModel node,
+ # so the embedded model is built from the same data with it removed.
+ del document_data["version"]
semantic_model = OssieSemanticModel.model_validate(document_data)
assert document.model_dump(exclude={"version"}) == semantic_model.model_dump()
-def test_only_document_rejects_extra_fields(document_data: dict) -> None:
- document_data["vendor_extension"] = "unknown"
+@pytest.mark.parametrize(
+ ("container_path", "unknown_key"),
+ [
+ ((), "vendor_extension"),
+ (("datasets", 0), "datasetn"),
+ (("datasets", 0, "fields", 0), "descriptionn"),
+ (("datasets", 0, "fields", 0, "expression"), "dialect"),
+ (("datasets", 0, "fields", 0, "expression", "dialects", 0), "expressions"),
+ (("datasets", 0, "fields", 0, "dimension"), "is_tile"),
+ (("metrics", 0), "expresssion"),
+ ],
+ ids=[
+ "document",
+ "dataset",
+ "field",
+ "expression",
+ "dialect_expression",
+ "dimension",
+ "metric",
+ ],
+)
+def test_every_object_rejects_unknown_fields(
+ document_data: dict, container_path: tuple, unknown_key: str
+) -> None:
+ """The SDK must reject what the schema rejects, not drop it silently.
+
+ Every node of `ossie-schema.json` but `AIContext` sets
+ `additionalProperties: false`. A nested model left at Pydantic's default
+ `extra="ignore"` accepts a mistyped key and loses it on serialization, so a
+ document the SDK round-trips cleanly still fails `ossie validate`.
+ """
+ container = document_data
+ for part in container_path:
+ container = container[part]
+ container[unknown_key] = "typo"
+
+ with pytest.raises(ValidationError) as error:
+ OssieDocument.model_validate(document_data)
+
+ assert any(
+ item["loc"] == (*container_path, unknown_key)
+ and item["type"] == "extra_forbidden"
+ for item in error.value.errors()
+ ), error.value.errors()
+
+
+def test_relationship_rejects_unknown_fields(document_data: dict) -> None:
+ document_data["relationships"] = [
+ {
+ "name": "orders_to_customers",
+ "from": "events",
+ "to": "orders",
+ "from_columns": ["id"],
+ "to_columns": ["id"],
+ "targeet": "typo",
+ }
+ ]
+
+ with pytest.raises(ValidationError) as error:
+ OssieDocument.model_validate(document_data)
+
+ assert any(
+ item["loc"] == ("relationships", 0, "targeet")
+ and item["type"] == "extra_forbidden"
+ for item in error.value.errors()
+ )
+
- embedded = OssieSemanticModel.model_validate(document_data)
- assert not hasattr(embedded, "vendor_extension")
- assert "vendor_extension" not in embedded.model_dump()
+def test_custom_extension_rejects_unknown_fields(document_data: dict) -> None:
+ document_data["custom_extensions"] = [
+ {"vendor_name": "DBT", "data": "{}", "datta": "typo"}
+ ]
with pytest.raises(ValidationError) as error:
OssieDocument.model_validate(document_data)
assert any(
- item["loc"] == ("vendor_extension",) and item["type"] == "extra_forbidden"
+ item["loc"] == ("custom_extensions", 0, "datta")
+ and item["type"] == "extra_forbidden"
for item in error.value.errors()
)
+def test_ai_context_keeps_unknown_fields(document_data: dict) -> None:
+ """`AIContext` is the one open node in the schema, so extras stay allowed."""
+ document_data["datasets"][0]["ai_context"] = {"synonyms": ["events"], "glossary": "x"}
+
+ document = OssieDocument.model_validate(document_data)
+
+ assert document.datasets[0].ai_context.glossary == "x"
+ assert "glossary" in document.model_dump(by_alias=True, exclude_none=True, mode="json")[
+ "datasets"
+ ][0]["ai_context"]
+
+
+def test_unknown_field_names_match_the_schema_allowlist() -> None:
+ """Each forbidding schema node needs a forbidding model, and vice versa.
+
+ Without this, a node added to the schema later can keep Pydantic's
+ `extra="ignore"` default and reintroduce the silent drop the SDK had before.
+ """
+ schema_path = Path(__file__).parents[2] / "core-spec" / "ossie-schema.json"
+ schema = json.loads(schema_path.read_text())
+
+ models = {
+ "CustomExtension": OssieCustomExtension,
+ "DialectExpression": OssieDialectExpression,
+ "Expression": OssieExpression,
+ "Dimension": OssieDimension,
+ "Field": OssieField,
+ "Dataset": OssieDataset,
+ "Relationship": OssieRelationship,
+ "Metric": OssieMetric,
+ "SemanticModel": OssieSemanticModel,
+ }
+
+ forbidden_in_schema = {
+ name
+ for name, node in schema["$defs"].items()
+ if node.get("additionalProperties") is False
+ }
+ assert forbidden_in_schema == set(models), (
+ "the schema gained or dropped an additionalProperties: false node; "
+ "update the model map and configs together"
+ )
+ for name, model in models.items():
+ assert model.model_config.get("extra") == "forbid", f"{model.__name__} must forbid"
+
+ assert OssieAIContextObject.model_config.get("extra") == "allow"
+ assert schema["$defs"]["AIContext"].get("additionalProperties") is not False
+
+
def test_invalid_datatype_is_rejected(document_data: dict) -> None:
field = document_data["datasets"][0]["fields"][0]
field["datatype"] = "timestamp"