diff --git a/converters/ontology/src/ossie_ontology/converter/ossie_to_spec/converter.py b/converters/ontology/src/ossie_ontology/converter/ossie_to_spec/converter.py index b63bf9a47..c367285f5 100644 --- a/converters/ontology/src/ossie_ontology/converter/ossie_to_spec/converter.py +++ b/converters/ontology/src/ossie_ontology/converter/ossie_to_spec/converter.py @@ -79,6 +79,7 @@ def convert(model: OssieOntology) -> OssieSpec: requires=[f.raw_expr for f in ont.requires], ontology=_convert_ontology_concepts(ont), ontology_mappings=ontology_mappings, + prefixes=model.prefixes, ) @@ -117,6 +118,7 @@ def _convert_concept(concept: Concept, rels: list[Relationship]) -> ConceptCompo derived_by=[f.raw_expr for f in concept.derived_by], requires=[f.raw_expr for f in concept.requires], relationships=[_convert_relationship(rel) for rel in rels], + iri=concept.iri, ) @@ -135,6 +137,7 @@ def _convert_relationship(rel: Relationship) -> SpecRelationship: multiplicity=multiplicity, # type: ignore[arg-type] derived_by=[f.raw_expr for f in rel.derived_by], requires=[f.raw_expr for f in rel.requires], + iri=rel.iri, ) diff --git a/converters/ontology/src/ossie_ontology/converter/spec_to_ossie/converter.py b/converters/ontology/src/ossie_ontology/converter/spec_to_ossie/converter.py index 75992421b..776a2dcae 100644 --- a/converters/ontology/src/ossie_ontology/converter/spec_to_ossie/converter.py +++ b/converters/ontology/src/ossie_ontology/converter/spec_to_ossie/converter.py @@ -99,6 +99,7 @@ def convert(self, spec: OssieSpec) -> OssieOntology: description=spec.description, ai_context=spec.ai_context, version=spec.version, + prefixes=spec.prefixes, ) self._populate_ontology(ontology, spec) @@ -131,6 +132,7 @@ def _populate_ontology(self, ontology: OntologyComponent, spec: OssieSpec) -> No type=ConceptType.from_value(concept_spec.type), description=concept_spec.description, extends=extends, + iri=concept_spec.iri, ) ) @@ -219,6 +221,7 @@ def _convert_relationship( description=rel_spec.description, verbalizes=list(rel_spec.verbalizes) if rel_spec.verbalizes else None, multiplicity=multiplicity, + iri=rel_spec.iri, ) ontology.add_relationship(relationship) diff --git a/converters/ontology/src/ossie_ontology/model.py b/converters/ontology/src/ossie_ontology/model.py index 21c722288..9badc8837 100644 --- a/converters/ontology/src/ossie_ontology/model.py +++ b/converters/ontology/src/ossie_ontology/model.py @@ -98,6 +98,7 @@ class Concept: _derived_by: list[Formula] _requires: list[Formula] _is_component: bool + _iri: str | None def __init__( self, @@ -109,7 +110,8 @@ def __init__( identify_by: dict[str, Relationship] | None = None, derived_by: list[Formula] | None = None, requires: list[Formula] | None = None, - is_component: bool = True + is_component: bool = True, + iri: str | None = None, ): self._name = name self._type = type @@ -120,6 +122,7 @@ def __init__( self._derived_by = derived_by if derived_by else [] self._requires = requires if requires else [] self._is_component = is_component + self._iri = iri def add_require(self, require: Formula) -> None: self._requires.append(require) @@ -145,6 +148,10 @@ def type(self) -> ConceptType | None: def description(self) -> str | None: return self._description + @property + def iri(self) -> str | None: + return self._iri + @property def is_builtin(self) -> bool: return self._builtin @@ -207,6 +214,7 @@ class Relationship: _multiplicity: RelationshipMultiplicity | None _derived_by: list[Formula] _requires: list[Formula] + _iri: str | None def __init__( self, @@ -216,6 +224,7 @@ def __init__( description: str | None = None, verbalizes: list[str] | None = None, multiplicity: RelationshipMultiplicity | None = None, + iri: str | None = None, ): self._name = name self._container = container @@ -228,6 +237,7 @@ def __init__( self._verbalizations = parse_verbalizations(self, verbalizes) self._derived_by = [] self._requires = [] + self._iri = iri @property def name(self) -> str: @@ -245,6 +255,10 @@ def container(self) -> Concept: def description(self) -> str | None: return self._description + @property + def iri(self) -> str | None: + return self._iri + @property def signature(self) -> list[Concept]: return [role.player for role in self._roles] @@ -1016,6 +1030,7 @@ class OssieOntology: _ontology: OntologyComponent _ontology_mappings: list[OntologyMapping] _ontology_mapping_index: dict[str, OntologyMapping] + _prefixes: dict[str, str] def __init__( self, @@ -1024,6 +1039,7 @@ def __init__( description: str | None = None, ai_context: AiContext | None = None, version: str | None = None, + prefixes: dict[str, str] | None = None, ): self._name = name self._description = description @@ -1032,6 +1048,7 @@ def __init__( self._ontology = ontology self._ontology_mappings = [] self._ontology_mapping_index = {} + self._prefixes = dict(prefixes) if prefixes else {} @property def name(self) -> str: @@ -1063,6 +1080,11 @@ def add_ontology_mapping(self, mapping: OntologyMapping) -> None: def ontology_mappings(self) -> list[OntologyMapping]: return list(self._ontology_mappings) + @property + def prefixes(self) -> dict[str, str]: + """Namespace prefixes the document's QName `iri` values resolve against.""" + return dict(self._prefixes) + # --------------------------------------------------------------------------- # Verbalization parser (handles a list of verbalization patterns) diff --git a/converters/ontology/src/ossie_ontology/spec.py b/converters/ontology/src/ossie_ontology/spec.py index f2a59012f..637495c96 100644 --- a/converters/ontology/src/ossie_ontology/spec.py +++ b/converters/ontology/src/ossie_ontology/spec.py @@ -72,6 +72,7 @@ class Relationship(OssieObject): """ name: str description: str | None = None + iri: str | None = None roles: list[Role] = Field(default_factory=list) verbalizes: list[str] = Field(default_factory=list) multiplicity: Literal["OneToOne", "ManyToOne"] | None = None @@ -100,6 +101,7 @@ class ConceptComponent(OssieObject): concept: str type: Literal["EntityType", "ValueType"] | None = None description: str | None = None + iri: str | None = None extends: list[str] | None = None identify_by: list[str] = Field(default_factory=list) derived_by: list[str] = Field(default_factory=list) @@ -243,6 +245,7 @@ class OssieSpec(OssieObject): version: str | None = None name: str description: str | None = None + prefixes: dict[str, str] = Field(default_factory=dict) requires: list[str] = Field(default_factory=list) ai_context: AiContext | None = None ontology: list[ConceptComponent] = Field(default_factory=list) diff --git a/converters/ontology/tests/test_ossie_parser.py b/converters/ontology/tests/test_ossie_parser.py index d23808a2e..91b1976f2 100644 --- a/converters/ontology/tests/test_ossie_parser.py +++ b/converters/ontology/tests/test_ossie_parser.py @@ -356,3 +356,68 @@ def test_relationships_are_grouped_under_their_own_container(): "Alpha": ["a1", "a2"], "Beta": ["b1", "b2"], } + + +# ----- Global identifiers ----------------------------------------------- + +_IRI_SPEC = { + "version": "0.2.0.dev0", + "name": "Demo", + "prefixes": {"foaf": "http://xmlns.com/foaf/0.1/"}, + "ontology": [ + { + "concept": "Person", + "type": "EntityType", + "iri": "foaf:Person", + "identify_by": ["person_name"], + "relationships": [ + { + "name": "person_name", + "iri": "foaf:name", + "roles": [{"concept": "String"}], + "verbalizes": ["{Person} is identified by {String}"], + "multiplicity": "OneToOne", + } + ], + } + ], +} + + +def test_iri_and_prefixes_survive_a_round_trip(tmp_path: Path): + """A document that pins its concepts to an external vocabulary must come + back out carrying the same pins. + + `iri` and `prefixes` are what tie an Ossie ontology to RDF/OWL vocabularies, + so dropping them silently turns a mapped ontology into an unmapped one. + """ + path = tmp_path / "spec.yaml" + path.write_text(yaml.safe_dump(_IRI_SPEC)) + + model = OssieParser().parse(path) + + assert model.prefixes == {"foaf": "http://xmlns.com/foaf/0.1/"} + person = model.ontology.lookup_concept("Person") + assert person is not None + assert person.iri == "foaf:Person" + assert model.ontology.lookup_concept_relationship(person, "person_name").iri == "foaf:name" + + roundtrip_path = tmp_path / "roundtrip.yaml" + roundtrip_path.write_text(OssieToSpecConverter.convert(model).dump_yaml()) + reparsed = OssieParser().parse(roundtrip_path) + + assert reparsed.prefixes == model.prefixes + reparsed_person = reparsed.ontology.lookup_concept("Person") + assert reparsed_person.iri == "foaf:Person" + assert reparsed.ontology.lookup_concept_relationship(reparsed_person, "person_name").iri == "foaf:name" + + +def test_a_document_without_iris_dumps_neither_field(tmp_path: Path): + """Both fields are optional, so a document that declares neither must dump + exactly as it did before they existed.""" + path = _write_spec(tmp_path, [_concept("Gadget")]) + + dumped = OssieToSpecConverter.convert(OssieParser().parse(path)).dump_yaml() + + assert "iri:" not in dumped + assert "prefixes:" not in dumped