diff --git a/converters/ontology/README.md b/converters/ontology/README.md index f47a1277..c156343a 100644 --- a/converters/ontology/README.md +++ b/converters/ontology/README.md @@ -19,13 +19,21 @@ # Ossie Ontology Converters -Converters between Ossie, Palantir, and Spec ontology formats. +Converters between Ossie, Palantir, LinkML, and Spec ontology formats. | Converter | Direction | |---------------------|-----------| | `palantir_to_ossie` | Palantir ontology → Ossie model | | `ossie_to_spec` | Ossie model → Spec YAML | | `spec_to_ossie` | Spec YAML → Ossie model | +| `linkml_to_ossie` | LinkML schema → Ossie model | +| `ossie_to_linkml` | Ossie model → LinkML schema | + +### LinkML + +The LinkML converters use the natively-compiled [LinkML-Scala](https://github.com/NeverBlink-OSS/linkml-scala) library (see: [converter source code](https://github.com/NeverBlink-OSS/linkml-scala/tree/main/generator/src/eu/neverblink/linkml/generator/ossie)). This repository only provides wrappers – please file any issues [here](https://github.com/NeverBlink-OSS/linkml-scala/issues). + +The Ossie <-> LinkML mapping and its limitations are documented [here](https://github.com/NeverBlink-OSS/linkml-scala/blob/main/docs/ossie_mapping.md). In general, LinkML supports only a subset of restriction expressions in Ossie, `derived_by` is not yet supported, and `ontology_mappings` are not representable in LinkML. Conversely, Ossie does not support many of the features of LinkML, such as all possible inheritance patterns. We are working to iteratively improve the coverage of the mapping in both directions. ## Prerequisites @@ -62,6 +70,8 @@ The package is importable as `ossie_ontology` after installation: from ossie_ontology.converter.palantir_to_ossie.converter import PalantirToOssieConverter from ossie_ontology.converter.ossie_to_spec.converter import OssieToSpecConverter from ossie_ontology.converter.spec_to_ossie.converter import SpecToOssieConverter +from ossie_ontology.converter.linkml_to_ossie.converter import LinkmlToOssieConverter +from ossie_ontology.converter.ossie_to_linkml.converter import OssieToLinkmlConverter ``` ## Scripts @@ -94,6 +104,30 @@ SNOWFLAKE_DATABASE_NAME=MY_DB SNOWFLAKE_SCHEMA_NAME=MY_SCHEMA \ uv run python scripts/palantir_to_ossie.py path/to/palantir_export.zip ``` +### `scripts/ossie_to_linkml.py` + +Converts an Ossie ontology (YAML or JSON) into the LinkML schema that describes it, printed to stdout. + +**Usage:** + +```bash +uv run python scripts/ossie_to_linkml.py path/to/ossie.yaml +# Second argument is optional, and if provided will be used as the schema's `id`: +uv run python scripts/ossie_to_linkml.py path/to/ossie.yaml https://example.org/my-schema +``` + +### `scripts/linkml_to_ossie.py` + +Converts a LinkML schema into an Ossie-compliant YAML representation of the ontology it describes, printed to stdout. The schema's `imports` are resolved from disk. + +**Usage:** + +```bash +uv run python scripts/linkml_to_ossie.py path/to/schema.yaml +``` + +A schema can load and still have errors and warnings against it. Errors and warnings are written to stderr, only fatal problems stop the run. + ## Running the tests ```bash diff --git a/converters/ontology/pyproject.toml b/converters/ontology/pyproject.toml index af6c7ea8..fb81e53a 100644 --- a/converters/ontology/pyproject.toml +++ b/converters/ontology/pyproject.toml @@ -28,7 +28,7 @@ dev = [ [project] name = "apache-ossie-ontology" version = "0.1.0" -description = "Ossie ontology converters — Palantir → Ossie, Ossie → Spec, Spec → Ossie" +description = "Ossie ontology converters — Palantir → Ossie, Spec ↔ Ossie, LinkML ↔ Ossie" authors = [{ name = "RelationalAI", email = "support@relational.ai" }] requires-python = ">=3.11" readme = "README.md" @@ -39,6 +39,7 @@ keywords = [ "Ontology" ] dependencies = [ + "neverblink-linkml", "pydantic", "pyyaml", ] diff --git a/converters/ontology/scripts/linkml_to_ossie.py b/converters/ontology/scripts/linkml_to_ossie.py new file mode 100644 index 00000000..1460bb35 --- /dev/null +++ b/converters/ontology/scripts/linkml_to_ossie.py @@ -0,0 +1,61 @@ +# 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. + + +# Description: +# +# This script converts a LinkML schema into an Ossie compliant YAML +# representation of the ontology it describes, printed to stdout. The schema's +# 'imports' are resolved from disk, exactly as the linkml-scala CLI resolves +# them. +# +# A schema can load and still have errors and warnings against it. +# Any issues the loader reports are written to stderr. Only fatal problems stop the run. +# The full mapping and all limitations are documented at +# https://github.com/NeverBlink-OSS/linkml-scala/blob/main/docs/ossie_mapping.md +# +# Usage: +# +# $ python linkml_to_ossie.py +# +# Outputs: +# +# - stdout: The Ossie ontology, as YAML +# - stderr: Schema issues reported while loading +# +import sys +from pathlib import Path + +import linkml_scala + +from ossie_ontology.converter.linkml_to_ossie.converter import LinkmlToOssieConverter + +if __name__ == "__main__": + if len(sys.argv) != 2: + sys.exit(f"Usage: {sys.argv[0]} ") + + path = Path(sys.argv[1]) + + # Loaded here rather than through LinkmlToOssieConverter.convert_file so the + # schema's own issues can be reported before anything is converted. + with linkml_scala.load_file(path) as schema: + for issue in schema.issues(): + print(f"{issue.get('severity')}: {issue.get('message')}", file=sys.stderr) + + spec = LinkmlToOssieConverter().convert_to_spec(schema) + + print(spec.dump_yaml()) diff --git a/converters/ontology/scripts/ossie_to_linkml.py b/converters/ontology/scripts/ossie_to_linkml.py new file mode 100644 index 00000000..87b25c25 --- /dev/null +++ b/converters/ontology/scripts/ossie_to_linkml.py @@ -0,0 +1,55 @@ +# 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. + + +# Description: +# +# This script converts an Ossie ontology into the LinkML schema that describes +# it, printed to stdout. The input is a single Ossie document, as YAML or JSON. +# +# Only the ontology crosses over: LinkML has nowhere to put an +# 'ontology_mappings' block, so datasets, join paths and metrics are dropped. +# The full mapping and all limitations are documented at +# https://github.com/NeverBlink-OSS/linkml-scala/blob/main/docs/ossie_mapping.md +# +# Usage: +# +# $ python ossie_to_linkml.py [schema_id] +# +# An Ossie ontology carries no schema id of its own, so LinkML gets a +# placeholder built from the ontology's name unless one is given here. +# +# Outputs: +# +# - stdout: The LinkML schema, as YAML +# +import sys +from pathlib import Path + +from ossie_ontology.converter.ossie_to_linkml.converter import OssieToLinkmlConverter +from ossie_ontology.parser import OssieParser + +if __name__ == "__main__": + if len(sys.argv) not in (2, 3): + sys.exit(f"Usage: {sys.argv[0]} [schema id]") + + path = Path(sys.argv[1]) + schema_id = sys.argv[2] if len(sys.argv) == 3 else None + + model = OssieParser().parse(path) + + print(OssieToLinkmlConverter.convert(model, schema_id=schema_id)) diff --git a/converters/ontology/src/ossie_ontology/__init__.py b/converters/ontology/src/ossie_ontology/__init__.py index 9579ef3b..9ed2790d 100644 --- a/converters/ontology/src/ossie_ontology/__init__.py +++ b/converters/ontology/src/ossie_ontology/__init__.py @@ -51,6 +51,8 @@ from ossie_ontology.converter.spec_to_ossie.converter import SpecToOssieConverter from ossie_ontology.converter.ossie_to_spec.converter import OssieToSpecConverter from ossie_ontology.converter.palantir_to_ossie.converter import PalantirToOssieConverter +from ossie_ontology.converter.linkml_to_ossie.converter import LinkmlToOssieConverter +from ossie_ontology.converter.ossie_to_linkml.converter import OssieToLinkmlConverter __all__ = [ # Model — ontology layer @@ -88,4 +90,6 @@ "SpecToOssieConverter", "OssieToSpecConverter", "PalantirToOssieConverter", + "LinkmlToOssieConverter", + "OssieToLinkmlConverter", ] diff --git a/converters/ontology/src/ossie_ontology/converter/linkml_to_ossie/__init__.py b/converters/ontology/src/ossie_ontology/converter/linkml_to_ossie/__init__.py new file mode 100644 index 00000000..13a83393 --- /dev/null +++ b/converters/ontology/src/ossie_ontology/converter/linkml_to_ossie/__init__.py @@ -0,0 +1,16 @@ +# 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. diff --git a/converters/ontology/src/ossie_ontology/converter/linkml_to_ossie/converter.py b/converters/ontology/src/ossie_ontology/converter/linkml_to_ossie/converter.py new file mode 100644 index 00000000..91f1c3e2 --- /dev/null +++ b/converters/ontology/src/ossie_ontology/converter/linkml_to_ossie/converter.py @@ -0,0 +1,120 @@ +# 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. + +"""Converter from a LinkML schema to OssieOntology (runtime semantic model). + +`linkml_scala` – the `neverblink-linkml` distribution – loads the schema and +emits an Ossie document from it. This module feeds that through the existing +spec -> model conversion, so the result is the same OssieOntology every other +converter in this package produces. + +Pairs with ossie_to_linkml.OssieToLinkmlConverter for the other direction.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Mapping + +import linkml_scala + +from ossie_ontology.converter.spec_to_ossie.converter import SpecToOssieConverter +from ossie_ontology.model import FormulaFactory, MappingFormulaFactory, OssieOntology +from ossie_ontology.spec import OssieSpec + + +class LinkmlToOssieConverter: + """Converts a LinkML schema to OssieOntology (runtime model). + + Takes the same *formula_factory* / *mapping_formula_factory* as + SpecToOssieConverter, because it hands the spec -> model step to it. + + model = LinkmlToOssieConverter().convert_file("model.yaml") + model = LinkmlToOssieConverter(formula_factory=my_parser).convert(schema) + + `convert` takes an already-loaded `linkml_scala.Schema`; use it when you + want to run other generators over the same schema, since loading is the + expensive part. `convert_file` and `convert_text` load and release one for + you. + """ + + def __init__(self, formula_factory: FormulaFactory | None = None, + mapping_formula_factory: MappingFormulaFactory | None = None): + self._formula_factory = formula_factory or FormulaFactory() + self._mapping_formula_factory = mapping_formula_factory or MappingFormulaFactory() + + def convert( + self, + schema: linkml_scala.Schema, + pruning_mode: str = "skip", + tree_root: str | None = None, + metadata_language: str = "en", + ) -> OssieOntology: + """Convert a loaded schema. + + *pruning_mode* selects which elements become concepts, and *tree_root* + names the class to prune from (only with `pruning_mode="treeRoot"`). + *metadata_language* picks the language of `description` fields in + schemas that carry translations. + """ + spec = self.convert_to_spec( + schema, + pruning_mode=pruning_mode, + tree_root=tree_root, + metadata_language=metadata_language, + ) + return SpecToOssieConverter( + formula_factory=self._formula_factory, + mapping_formula_factory=self._mapping_formula_factory, + ).convert(spec) + + def convert_to_spec( + self, + schema: linkml_scala.Schema, + pruning_mode: str = "skip", + tree_root: str | None = None, + metadata_language: str = "en", + ) -> OssieSpec: + """Convert a loaded schema, stopping at the spec DTO. + + The shorter path when the caller only wants to write the Ossie document + out rather than build the runtime model. + """ + return OssieSpec.load_yaml( + schema.ossie( + pruning_mode=pruning_mode, + tree_root=tree_root, + output_format="yaml", + metadata_language=metadata_language, + ) + ) + + def convert_file(self, path: str | Path, **options) -> OssieOntology: + """Load a schema from disk — resolving its `imports` from disk too — + and convert it. *options* are those of `convert`.""" + with linkml_scala.load_file(path) as schema: + return self.convert(schema, **options) + + def convert_text( + self, + schema_text: str, + imports: Mapping[str, str] | None = None, + **options, + ) -> OssieOntology: + """Convert a schema held in memory, resolving its `imports` against the + *imports* map of filename to YAML text. *options* are those of `convert`.""" + with linkml_scala.load_string(schema_text, imports) as schema: + return self.convert(schema, **options) diff --git a/converters/ontology/src/ossie_ontology/converter/ossie_to_linkml/__init__.py b/converters/ontology/src/ossie_ontology/converter/ossie_to_linkml/__init__.py new file mode 100644 index 00000000..13a83393 --- /dev/null +++ b/converters/ontology/src/ossie_ontology/converter/ossie_to_linkml/__init__.py @@ -0,0 +1,16 @@ +# 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. diff --git a/converters/ontology/src/ossie_ontology/converter/ossie_to_linkml/converter.py b/converters/ontology/src/ossie_ontology/converter/ossie_to_linkml/converter.py new file mode 100644 index 00000000..1d90e330 --- /dev/null +++ b/converters/ontology/src/ossie_ontology/converter/ossie_to_linkml/converter.py @@ -0,0 +1,79 @@ +# 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. + +"""Converter from OssieOntology (runtime semantic model) to a LinkML schema. + +The translation is done by `linkml_scala` – the `neverblink-linkml` +distribution, a natively compiled LinkML implementation. This module only +renders the runtime model back into an Ossie document and hands it over. + +Pairs with linkml_to_ossie.LinkmlToOssieConverter for the other direction.""" + +from __future__ import annotations + +import linkml_scala + +from ossie_ontology.converter.ossie_to_spec.converter import OssieToSpecConverter +from ossie_ontology.model import OssieOntology +from ossie_ontology.spec import OssieSpec + + +class OssieToLinkmlConverter: + """Converts an Ossie ontology into a LinkML schema, returned as text. + + Only the ontology crosses over. LinkML describes types and their slots and + has nowhere to put an `ontology_mappings` block, so datasets, join paths + and metrics are dropped. The full mapping is documented at + https://github.com/NeverBlink-OSS/linkml-scala/blob/main/docs/ossie_mapping.md + + schema_yaml = OssieToLinkmlConverter.convert(model) + """ + + @staticmethod + def convert( + model: OssieOntology, + schema_id: str | None = None, + output_format: str = "yaml", + ) -> str: + """Convert a runtime model. + + *schema_id* becomes the schema's `id`. An Ossie ontology carries no id + of its own, so leaving it unset yields a placeholder built from the + ontology's name. *output_format* is `yaml` or `json`. + """ + return OssieToLinkmlConverter.convert_spec( + OssieToSpecConverter.convert(model), + schema_id=schema_id, + output_format=output_format, + ) + + @staticmethod + def convert_spec( + spec: OssieSpec, + schema_id: str | None = None, + output_format: str = "yaml", + ) -> str: + """Convert a spec DTO, skipping the runtime model. + + The shorter path when a document was read straight off disk and never + needed to be built out into an OssieOntology. + """ + return linkml_scala.from_ossie( + spec.dump_yaml(), + schema_id=schema_id, + output_format=output_format, + ) diff --git a/converters/ontology/tests/test_linkml_converter.py b/converters/ontology/tests/test_linkml_converter.py new file mode 100644 index 00000000..65312ca2 --- /dev/null +++ b/converters/ontology/tests/test_linkml_converter.py @@ -0,0 +1,292 @@ +# 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. + +"""Tests for the Ossie <-> LinkML converters. + +The translation itself lives in `linkml_scala` (the `neverblink-linkml` +package), which has its own test suite: 400+ tests, including unit tests, +integration with Ossie JSON Schema and validator, round-trip tests, and +expression parser fuzzing. + +The tests here cover the integration: that the wiring works correctly and +that a round-trip through LinkML keeps the ontology intact.""" + +from __future__ import annotations + +import json + +import linkml_scala +import pytest +import yaml + +from ossie_ontology.converter.linkml_to_ossie.converter import LinkmlToOssieConverter +from ossie_ontology.converter.ossie_to_linkml.converter import OssieToLinkmlConverter +from ossie_ontology.converter.spec_to_ossie.converter import SpecToOssieConverter +from ossie_ontology.model import OssieOntology +from ossie_ontology.spec import OssieSpec + + +@pytest.fixture +def flights_linkml(flights_model: OssieOntology) -> str: + return OssieToLinkmlConverter.convert(flights_model) + + +# ----- Ossie -> LinkML --------------------------------------------------- + +def test_schema_loads_without_errors(flights_linkml: str): + with linkml_scala.load_string(flights_linkml) as schema: + # A schema can load and still have errors against it, so check the + # report rather than just that the load returned. + assert schema.issues(linkml_scala.ERROR) == [] + assert schema.issues(linkml_scala.FATAL) == [] + + +def test_schema_carries_concepts(flights_linkml: str): + schema = yaml.safe_load(flights_linkml) + assert schema["name"] == "Flights" + assert schema["description"] == "Ontology of flights into and out of airports." + # Entity types become classes, value types become types. + assert {"Airport", "Flight", "Carrier"} <= set(schema["classes"]) + assert {"CancelationCode", "DegreesLatitude"} <= set(schema["types"]) + # Relationships under a concept become that class's attributes. + assert "code" in schema["classes"]["Airport"]["attributes"] + + +def test_schema_id_defaults_and_can_be_overridden(flights_model: OssieOntology): + # An Ossie ontology has no id of its own, so one is made up from its name. + assert yaml.safe_load(OssieToLinkmlConverter.convert(flights_model))["id"] == "https://example.org/flights" + + given = "https://ossie.apache.org/flights" + assert yaml.safe_load(OssieToLinkmlConverter.convert(flights_model, schema_id=given))["id"] == given + + +def test_json_output_format(flights_model: OssieOntology): + schema = json.loads(OssieToLinkmlConverter.convert(flights_model, output_format="json")) + assert schema["name"] == "Flights" + + +def test_convert_spec_matches_convert(flights_model: OssieOntology, flights_path): + """Going straight from the spec DTO describes the same schema as going via + the runtime model. + + Compared as parsed YAML, not as text: building the runtime model sorts + concepts topologically, so the two agree on content but not on the order + classes and types are emitted in. + """ + spec = OssieSpec.load_yaml(flights_path.read_text(encoding="utf-8")) + assert yaml.safe_load(OssieToLinkmlConverter.convert_spec(spec)) == yaml.safe_load( + OssieToLinkmlConverter.convert(flights_model) + ) + + +# ----- LinkML -> Ossie --------------------------------------------------- + +def test_convert_text_returns_model(flights_linkml: str): + model = LinkmlToOssieConverter().convert_text(flights_linkml) + assert model.name == "Flights" + assert model.version == "0.2.0.dev0" + assert model.description == "Ontology of flights into and out of airports." + + +def test_convert_file_reads_from_disk(flights_linkml: str, tmp_path): + path = tmp_path / "flights.linkml.yaml" + path.write_text(flights_linkml, encoding="utf-8") + assert LinkmlToOssieConverter().convert_file(path).name == "Flights" + + +def test_convert_to_spec_stops_at_the_dto(flights_linkml: str): + with linkml_scala.load_string(flights_linkml) as schema: + spec = LinkmlToOssieConverter().convert_to_spec(schema) + assert isinstance(spec, OssieSpec) + assert spec.name == "Flights" + + +def test_converters_do_not_share_formula_factories(): + a, b = LinkmlToOssieConverter(), LinkmlToOssieConverter() + assert a._formula_factory is not b._formula_factory + assert a._mapping_formula_factory is not b._mapping_formula_factory + + +# ----- Round-trip -------------------------------------------------------- + +def test_roundtrip_preserves_concepts_and_relationships(flights_model: OssieOntology, flights_linkml: str): + back = LinkmlToOssieConverter().convert_text(flights_linkml) + + def names(model: OssieOntology) -> tuple[set[str], set[str]]: + ontology = model.ontology + return ( + {c.name for c in ontology.concepts(exclude_builtin=True)}, + {r.full_name for r in ontology.relationships}, + ) + + assert names(back) == names(flights_model) + + +def test_roundtrip_preserves_concept_types_and_identifiers(flights_model: OssieOntology, flights_linkml: str): + back = LinkmlToOssieConverter().convert_text(flights_linkml) + for concept in flights_model.ontology.concepts(exclude_builtin=True): + returned = back.ontology.lookup_concept(concept.name) + assert returned is not None, concept.name + assert returned.type == concept.type, concept.name + assert set(returned.identify_by) == set(concept.identify_by), concept.name + + +def test_roundtrip_drops_ontology_mappings(flights_model: OssieOntology, flights_linkml: str): + # Documents a known loss: LinkML describes types and their slots, so there + # is nowhere to put datasets, join paths or metrics. + assert flights_model.ontology_mappings != [] + assert LinkmlToOssieConverter().convert_text(flights_linkml).ontology_mappings == [] + + +# ----- One full example, in both YAML formats ---------------------------- + +# The two documents below describe the same little ontology: one in Ossie's +# YAML, one in LinkML's. They convert into each other exactly: +# +# Ossie LinkML +# ---------------------------------- ------------------------------------ +# name, description name, description +# (nothing) id, prefixes, imports, default_range +# concept, type: ValueType an entry under `types` +# extends: [ Integer ] typeof: integer +# requires: [ NrPages >= 1 ] minimum_value: 1 +# concept, type: EntityType an entry under `classes` +# relationships that class's `attributes` +# roles: [ { concept: Isbn } ] range: Isbn +# verbalizes title (the phrase, placeholders cut) +# identify_by: [ isbn ] identifier: true on that attribute +# requires: [ Book.isbn ] required: true on that attribute +# multiplicity: OneToOne, ManyToOne a single-valued attribute +# no multiplicity multivalued: true +# a camelCase relationship name a snake_case slot, alias keeps the +# original spelling +# the order relationships are in rank + +EXAMPLE_OSSIE = """\ +version: 0.2.0.dev0 +name: Books +description: A tiny ontology of books and the people who wrote them. +ontology: +- concept: Author + type: EntityType + description: A person who wrote a book. + identify_by: + - name + requires: + - Author.name + relationships: + - name: name + roles: + - concept: String + verbalizes: + - '{Author} is identified by {String}' + multiplicity: OneToOne +- concept: Book + type: EntityType + identify_by: + - isbn + requires: + - Book.isbn + relationships: + - name: isbn + roles: + - concept: Isbn + verbalizes: + - '{Book} is identified by {Isbn}' + multiplicity: OneToOne + - name: pages + roles: + - concept: NrPages + verbalizes: + - '{Book} has pages- {NrPages}' + multiplicity: ManyToOne + - name: writtenBy + roles: + - concept: Author + verbalizes: + - '{Book} is written by {Author}' +- concept: Isbn + type: ValueType + description: The identifier of a book. + extends: + - String +- concept: NrPages + type: ValueType + extends: + - Integer + requires: + - NrPages >= 1 +""" + +EXAMPLE_LINKML = """\ +id: https://example.org/books +name: Books +description: A tiny ontology of books and the people who wrote them. +prefixes: + linkml: https://w3id.org/linkml/ +imports: +- linkml:types +default_range: string +classes: + Book: + attributes: + isbn: + title: is identified by + identifier: true + required: true + rank: 1 + range: Isbn + pages: + title: has pages- + rank: 2 + range: NrPages + written_by: + title: is written by + alias: writtenBy + multivalued: true + rank: 3 + range: Author + Author: + description: A person who wrote a book. + attributes: + name: + title: is identified by + identifier: true + required: true + rank: 1 + range: string +types: + NrPages: + typeof: integer + minimum_value: 1 + Isbn: + description: The identifier of a book. + typeof: string +""" + + +def test_full_example_ossie_to_linkml(): + """The Ossie document above converts to exactly the LinkML schema above.""" + model = SpecToOssieConverter().convert(OssieSpec.load_yaml(EXAMPLE_OSSIE)) + assert yaml.safe_load(OssieToLinkmlConverter.convert(model)) == yaml.safe_load(EXAMPLE_LINKML) + + +def test_full_example_linkml_to_ossie(): + """And back: the LinkML schema above converts to exactly the Ossie document.""" + with linkml_scala.load_string(EXAMPLE_LINKML) as schema: + spec = LinkmlToOssieConverter().convert_to_spec(schema) + assert yaml.safe_load(spec.dump_yaml()) == yaml.safe_load(EXAMPLE_OSSIE) diff --git a/converters/ontology/uv.lock b/converters/ontology/uv.lock index 303af40e..b88766a7 100644 --- a/converters/ontology/uv.lock +++ b/converters/ontology/uv.lock @@ -16,6 +16,7 @@ name = "apache-ossie-ontology" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "neverblink-linkml" }, { name = "pydantic" }, { name = "pyyaml" }, ] @@ -28,6 +29,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "neverblink-linkml" }, { name = "pydantic" }, { name = "pyyaml" }, ] @@ -56,6 +58,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "neverblink-linkml" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/22/5f5220bce0e4dc530277b2c66b51d3396d3c85efcde9057d71647730be6d/neverblink_linkml-0.16.0.tar.gz", hash = "sha256:c51cc288e088d1870d1e6ab32b1637c4e17a14dcf754bed75aa8dc26a8839072", size = 62147511, upload-time = "2026-09-11T11:04:44.349Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/33/b0baadeea8de2e9d551ed3a2b66f07664c30c45847b2478822d5d5eaeb24/neverblink_linkml-0.16.0-py3-none-macosx_14_0_arm64.whl", hash = "sha256:5114fbcf34ab46802029ad4f3d9d57dd273498048a668426242320c583f047d9", size = 2560800, upload-time = "2026-09-11T11:04:29.892Z" }, + { url = "https://files.pythonhosted.org/packages/01/2c/d964360ecfedece7e8692cf1d1340e186b18504eff802ec2165182125a17/neverblink_linkml-0.16.0-py3-none-macosx_14_0_x86_64.whl", hash = "sha256:62da1c3347154a5aea9667b8e7d78fb46a71598e2d67cc86320d07f9a1decdbc", size = 2645851, upload-time = "2026-09-11T11:04:32.118Z" }, + { url = "https://files.pythonhosted.org/packages/14/02/9a2d2f5818dd3295512665d93480d6f55b62effa9fd092b77f9857ec4ddf/neverblink_linkml-0.16.0-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:a91aa7ac2824f1c28a287b594ae73e2bbe1a1246585b056dffdfe42a9368fdef", size = 3165269, upload-time = "2026-09-11T11:04:33.829Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/29df72d2cf9c44ad3eaca034bbf8198ba1b20f1c098969fa28a69bc6caaf/neverblink_linkml-0.16.0-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:877036202e5675aa10c7426b18ecce16539b7f0336912ff54569fc209819c8ce", size = 3408002, upload-time = "2026-09-11T11:04:35.368Z" }, + { url = "https://files.pythonhosted.org/packages/63/c4/df1abffa4fa41d8c3c84a3585b56b239478b4745e3e36ac06b87a893fa81/neverblink_linkml-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5bd9b7dd27b9e1e4047ed541403f35e75fd3a1f408d4c393532bdd6b5375721a", size = 3261571, upload-time = "2026-09-11T11:04:37.144Z" }, + { url = "https://files.pythonhosted.org/packages/fc/11/6bbe7d00c38711cc469457afcd2c97d1ca7ffa1ce51a7bd18d70a2dedbe5/neverblink_linkml-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6d288e8968a5c4ba243ef944687b71686c4ec47fa9558cd2aeacfb9fb3bb217e", size = 3391456, upload-time = "2026-09-11T11:04:38.67Z" }, + { url = "https://files.pythonhosted.org/packages/c8/5f/70034c23e8513de8b97af040d367e54bbbe9cb2fd75c61a148d334ffa4b8/neverblink_linkml-0.16.0-py3-none-win_amd64.whl", hash = "sha256:f322b796ac47ddf6c08269849f4e79a632adc046b658e9178cdd684066acd0d3", size = 2576404, upload-time = "2026-09-11T11:04:40.393Z" }, + { url = "https://files.pythonhosted.org/packages/c4/06/ede20e9b07322faea21efff92de1b595af364269eeaeb308f83edd8301ae/neverblink_linkml-0.16.0-py3-none-win_arm64.whl", hash = "sha256:f69c17850e3f2ed498d6addad7ce2b4d20715c5c8b4a07490aca8aabeda464c3", size = 2174930, upload-time = "2026-09-11T11:04:41.849Z" }, +] + [[package]] name = "packaging" version = "26.3"