Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,23 +21,35 @@

The Apache Ossie Python package provides Pydantic v2 models for the Apache Ossie semantic model specification. It is the shared foundation used by Apache Ossie converters to parse, construct, validate, and serialize Ossie documents from Python application.

Each `OssieDocument` is one semantic model: `name`, `datasets`, `relationships`,
Each `OssieSemanticModel` is one semantic model: `name`, `datasets`, `relationships`,
and `metrics` sit at the root alongside `version`.
Construct documents with `OssieDocument(name="sales", datasets=[...])` and access
Construct documents with `OssieSemanticModel(name="sales", datasets=[...])` and access
their datasets as `document.datasets`. JSON and YAML serialization use the same
flat shape, with `version` serialized first when included. The former
`semantic_model` wrapper is rejected. Unwrap old
single-model documents and split multi-model documents into separate files,
preserving model contents and setting `version` in each file before loading them.

Use `OssieDocument` for models embedded in ontology maps.
The same `OssieSemanticModel`, `version` included, is embedded in ontology maps.
Earlier releases used `OssieDocument` for semantic models; that role now belongs to
`OssieSemanticModel`, so replace `OssieDocument(...)` and
`OssieDocument.from_ossie_yaml(...)` with their `OssieSemanticModel` equivalents.

Load serialized documents with `OssieDocument.from_ossie_yaml(text)` or
`OssieDocument.from_ossie_json(text)`. Both reject duplicate mapping keys,
Load serialized documents with `OssieSemanticModel.from_ossie_yaml(text)` or
`OssieSemanticModel.from_ossie_json(text)`. Both reject duplicate mapping keys,
including keys in nested objects, so an earlier value is not silently lost.
The YAML loader preserves aliases, repeated merge directives and explicit
overrides of merged keys.

`OssieOntologyModel` mirrors `ontology/ontology.json`: concepts
(`OssieConceptComponent`), the relationships among them
(`OssieOntologyRelationship`, distinct from the dataset join `OssieRelationship`),
and, deprecated, embedded `ontology_mappings`.

`OssieSemanticModel` and `OssieOntologyModel` both extend `OssieDocument`, which holds
`version` and the `to_ossie_*` and `from_ossie_*` methods. An ontology file
therefore loads with `OssieOntologyModel.from_ossie_yaml(text)`, under the same checks.

## Development

### Prerequisites
Expand Down
24 changes: 24 additions & 0 deletions python/src/ossie/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
from ossie.models import (
OssieAIContext,
OssieAIContextObject,
OssieConceptComponent,
OssieConceptMapping,
OssieConceptType,
OssieCustomExtension,
OssieDataset,
OssieDataType,
Expand All @@ -34,15 +37,27 @@
OssieDocument,
OssieExpression,
OssieField,
OssieLinkMapping,
OssieMetric,
OssieMultiplicity,
OssieObjectMapping,
OssieOntologyMapping,
OssieOntologyModel,
OssieOntologyRelationship,
OssieReferentMapping,
OssieRelationship,
OssieRole,
OssieSemanticModel,
OssieVendor,
OssieVersion,
)

__all__ = [
"OssieAIContext",
"OssieAIContextObject",
"OssieConceptComponent",
"OssieConceptMapping",
"OssieConceptType",
"OssieCustomExtension",
"OssieDataset",
"OssieDataType",
Expand All @@ -52,8 +67,17 @@
"OssieDocument",
"OssieExpression",
"OssieField",
"OssieLinkMapping",
"OssieMetric",
"OssieMultiplicity",
"OssieObjectMapping",
"OssieOntologyMapping",
"OssieOntologyModel",
"OssieOntologyRelationship",
"OssieReferentMapping",
"OssieRelationship",
"OssieRole",
"OssieSemanticModel",
"OssieVendor",
"OssieVersion",
]
232 changes: 208 additions & 24 deletions python/src/ossie/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@
# specific language governing permissions and limitations
# under the License.

from __future__ import annotations

import json
import math
from enum import Enum
from typing import Annotated, Any, Literal, Optional, Union
from typing import Annotated, Any, Literal, Optional, Self, Union

import yaml
from pydantic import (
Expand Down Expand Up @@ -234,7 +236,7 @@ class OssieExpression(BaseModel):
dialects: Optional[list[OssieDialectExpression]] = Field(default=None, min_length=1)

@model_validator(mode="after")
def _require_sql_or_dialects(self) -> "OssieExpression":
def _require_sql_or_dialects(self) -> OssieExpression:
if self.sql is None and not self.dialects:
raise ValueError("expression requires at least one of 'sql' or 'dialects'")
return self
Expand Down Expand Up @@ -328,30 +330,22 @@ class OssieRelationship(BaseModel):
custom_extensions: Optional[list[OssieCustomExtension]] = None


class OssieSemanticModel(BaseModel):
"""Semantic model: the datasets, relationships, and metrics for one business domain."""

model_config = ConfigDict(frozen=True, extra="forbid")
# The specification version every Ossie document declares. Each schema pins it with
# `const`, so a document may not declare any other version. Mirrored as a Literal
# rather than a plain str, which would let the package serialize a version the
# schema rejects.
OssieVersion = Literal["0.2.0.dev0"]

name: str = Field(..., min_length=1)
description: Optional[str] = None
ai_context: Optional[OssieAIContext] = None
datasets: list[OssieDataset] = Field(..., min_length=1)
relationships: Optional[list[OssieRelationship]] = None
# Model-scoped: expressions reference fields by multi-part name (dataset.field) and may span datasets.
metrics: Optional[list[OssieMetric]] = None
custom_extensions: Optional[list[OssieCustomExtension]] = None

class OssieDocument(BaseModel):
"""The root of an Ossie file: the `version` it declares and how it is read and written.

class OssieDocument(OssieSemanticModel):
"""A single semantic model with document metadata at the root."""
`OssieSemanticModel` is a semantic model file and `OssieOntologyModel` an ontology file.
"""

model_config = ConfigDict(frozen=True, extra="forbid")

# The schema pins this with `const`, so a document may not declare any other
# version. Mirrored as a Literal rather than a plain str, which would let the
# package serialize a version the schema rejects.
version: Literal["0.2.0.dev0"] = "0.2.0.dev0"
version: OssieVersion = "0.2.0.dev0"

@model_serializer(mode="wrap")
def _serialize_document(self, handler: SerializerFunctionWrapHandler):
Expand Down Expand Up @@ -384,7 +378,7 @@ def to_ossie_json(self, **kwargs: Any) -> str:
return self.model_dump_json(**kwargs)

@classmethod
def from_ossie_yaml(cls, text: str) -> "OssieDocument":
def from_ossie_yaml(cls, text: str) -> Self:
"""Parse an Ossie YAML document, checking what pydantic alone cannot.

The counterpart of :meth:`to_ossie_yaml`. ``model_validate`` stays lenient,
Expand All @@ -411,7 +405,7 @@ def from_ossie_yaml(cls, text: str) -> "OssieDocument":
return cls._from_document(yaml.load(text, Loader=UniqueKeyLoader))

@classmethod
def from_ossie_json(cls, text: str) -> "OssieDocument":
def from_ossie_json(cls, text: str) -> Self:
"""Parse an Ossie JSON document, checking what pydantic alone cannot.

The counterpart of :meth:`to_ossie_json`; see :meth:`from_ossie_yaml` for what
Expand All @@ -420,7 +414,7 @@ def from_ossie_json(cls, text: str) -> "OssieDocument":
return cls._from_document(json.loads(text, object_pairs_hook=unique_json_object))

@classmethod
def _from_document(cls, data: Any) -> "OssieDocument":
def _from_document(cls, data: Any) -> Self:
for reason in (_NULL, _NON_FINITE):
paths = [path for path, why in _invalid_properties(data) if why == reason]
if paths:
Expand All @@ -429,8 +423,198 @@ def _from_document(cls, data: Any) -> "OssieDocument":
if len(paths) > _MAX_REPORTED:
listed += f", and {len(paths) - _MAX_REPORTED} more"
raise ValueError(f"{reason}: {listed}")
if isinstance(data, dict) and "version" in cls.model_fields and "version" not in data:
if isinstance(data, dict) and "version" not in data:
# The field carries a default so Python construction needs no version, but
# the schema lists it as required, so a document without one is invalid.
raise ValueError("document is missing the required property: version")
return cls.model_validate(data)


class OssieSemanticModel(OssieDocument):
"""Semantic model: the datasets, relationships, and metrics for one business domain."""

name: str = Field(..., min_length=1)
description: Optional[str] = None
ai_context: Optional[OssieAIContext] = None
datasets: list[OssieDataset] = Field(..., min_length=1)
relationships: Optional[list[OssieRelationship]] = None
# Model-scoped: expressions reference fields by multi-part name (dataset.field) and may span datasets.
metrics: Optional[list[OssieMetric]] = None
custom_extensions: Optional[list[OssieCustomExtension]] = None


def _require_any(model: BaseModel, *names: str) -> None:
"""Mirror a schema `anyOf` of `required` lists: at least one of `names` is set."""
if all(getattr(model, name) is None for name in names):
listed = " or ".join(f"'{name}'" for name in names)
raise ValueError(f"{type(model).__name__} requires at least one of {listed}")


# ---------------------------------------------------------------------------
# Ontology (ontology/ontology.json)
# ---------------------------------------------------------------------------


class OssieConceptType(str, Enum):
"""Whether a concept is an entity type or a value type."""

ENTITY_TYPE = "EntityType"
VALUE_TYPE = "ValueType"


class OssieMultiplicity(str, Enum):
"""Multiplicity constraint on the last role of an ontology relationship."""

MANY_TO_ONE = "ManyToOne"
ONE_TO_ONE = "OneToOne"


class OssieRole(BaseModel):
"""An additional role in an ontology relationship.

The first role is implicit: it is played by the concept the relationship is
declared under.
"""

model_config = ConfigDict(frozen=True, extra="forbid")

concept: str
# Needed only when the same concept plays more than one role in the relationship.
name: Optional[str] = None


class OssieOntologyRelationship(BaseModel):
"""Relationship between concepts, declared under the concept playing its first role.

Not to be confused with `OssieRelationship`, the join between two datasets of a
semantic model.
"""

model_config = ConfigDict(frozen=True, extra="forbid")

name: str
description: Optional[str] = None
roles: Optional[list[OssieRole]] = None
multiplicity: Optional[OssieMultiplicity] = None
derived_by: Optional[list[str]] = None
requires: Optional[list[str]] = None
verbalizes: list[str]
iri: Optional[str] = None


class OssieConceptComponent(BaseModel):
"""A concept and the relationships where it plays the first role.

The schema calls this node `OntologyComponent`.
"""

model_config = ConfigDict(frozen=True, extra="forbid")

concept: str
type: OssieConceptType
description: Optional[str] = None
extends: Optional[list[str]] = None
derived_by: Optional[list[str]] = None
identify_by: Optional[list[str]] = None
requires: Optional[list[str]] = None
relationships: Optional[list[OssieOntologyRelationship]] = None
iri: Optional[str] = None


# ---------------------------------------------------------------------------
# Ontology mappings (ontology/ontology.json)
# ---------------------------------------------------------------------------


class OssieReferentMapping(BaseModel):
"""Maps logical constructs to an identifying relationship of an entity type.

A leaf carries an `expression`; a referent whose player is itself an entity
nests further `referent_mappings` instead.
"""

model_config = ConfigDict(frozen=True, extra="forbid")

relationship: str
expression: Optional[str] = None
referent_mappings: Optional[list[OssieReferentMapping]] = Field(default=None, min_length=1)

@model_validator(mode="after")
def _require_expression_or_referents(self) -> OssieReferentMapping:
_require_any(self, "expression", "referent_mappings")
return self


class OssieObjectMapping(BaseModel):
"""Identifies objects of a concept from the values of one or more fields."""

model_config = ConfigDict(frozen=True, extra="forbid")

concept: Optional[str] = None
referent_mappings: Optional[list[OssieReferentMapping]] = Field(default=None, min_length=1)
expression: Optional[str] = None

@model_validator(mode="after")
def _require_expression_or_referents(self) -> OssieObjectMapping:
_require_any(self, "expression", "referent_mappings")
return self


class OssieLinkMapping(BaseModel):
"""A node in a tree of mappings to the links of ontology relationships.

A node's depth equals the arity of the relationship it names; each child extends
the tuple mapped by its parent by one role.
"""

model_config = ConfigDict(frozen=True, extra="forbid")

relationship: Optional[str] = None
object_mapping: OssieObjectMapping
children: Optional[list[OssieLinkMapping]] = None


class OssieConceptMapping(BaseModel):
"""Mappings that populate one concept and the relationships grouped under it."""

model_config = ConfigDict(frozen=True, extra="forbid")

concept: str
object_mappings: Optional[list[OssieObjectMapping]] = Field(default=None, min_length=1)
link_mappings: Optional[list[OssieLinkMapping]] = Field(default=None, min_length=1)

@model_validator(mode="after")
def _require_object_or_link_mappings(self) -> OssieConceptMapping:
_require_any(self, "object_mappings", "link_mappings")
return self


class OssieOntologyMapping(BaseModel):
"""A semantic model embedded in an ontology, with the mappings onto that ontology.

Deprecated by the schema, which accepts it only so existing documents continue
to validate. New mappings are standalone documents validated against
`ontology/mapping.json`.
"""

model_config = ConfigDict(frozen=True, extra="forbid")

name: Optional[str] = None
description: Optional[str] = None
# The schema embeds the whole core document here, `version` included.
semantic_model: OssieSemanticModel
concept_mappings: list[OssieConceptMapping]


class OssieOntologyModel(OssieDocument):
"""An ontology: concepts, the relationships among them, and their rules."""

name: str
description: Optional[str] = None
ai_context: Optional[OssieAIContext] = None
requires: Optional[list[str]] = None
ontology: list[OssieConceptComponent] = Field(..., min_length=1)
# Deprecated by the schema; see `OssieOntologyMapping`.
ontology_mappings: Optional[list[OssieOntologyMapping]] = None
# Namespace prefix -> the IRI it abbreviates, for `iri` values written as QNames.
prefixes: Optional[dict[str, str]] = None
Loading
Loading