From 4cf65e06aae4ce122cb796ab3f2dd5cc69e00f5b Mon Sep 17 00:00:00 2001 From: jdsika Date: Fri, 2 Oct 2026 16:52:46 +0200 Subject: [PATCH] feat(rdf): honor declared annotation ranges in OWL and SHACL Resolve annotation slots through the metaclasses declared by instantiates, including imported and inherited definitions. Use their scalar ranges to preserve the distinction between RDF nodes and literals in both generators. Replace vocabulary-name coercion with a shared declaration-driven resolver. rdfs:Resource includes literals, and URI-looking strings alone do not define an annotation's intended RDF term. Reuse URI validation, reject ambiguous or unsupported declared representations, and preserve undeclared metadata. Emit explicit annotations on local OWL types through the same path. Add functional cross-generator contracts and document explicit modelling, standards, supported metadata owners and serialization boundaries. Signed-off-by: jdsika --- docs/generators/owl.rst | 73 ++++++ .../linkml/generators/common/annotations.py | 78 +++++++ .../linkml/src/linkml/generators/owlgen.py | 13 ++ .../linkml/src/linkml/generators/shaclgen.py | 5 + .../test_declared_annotations.py | 207 ++++++++++++++++++ 5 files changed, 376 insertions(+) create mode 100644 packages/linkml/src/linkml/generators/common/annotations.py create mode 100644 tests/linkml/test_generators/test_declared_annotations.py diff --git a/docs/generators/owl.rst b/docs/generators/owl.rst index 4b6f076fe8..11e56832d3 100644 --- a/docs/generators/owl.rst +++ b/docs/generators/owl.rst @@ -67,6 +67,79 @@ Mapping .. note:: The current default settings for ``metaclasses`` and ``type-objects`` may change in the future +Declared annotation values +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +An annotation property name does not determine whether its value is a literal +or an RDF node. For example, ``rdfs:Resource`` includes literals, and OWL permits +both IRIs and literals as annotation values. The generator does not maintain a +list of vocabulary properties whose string values should be changed into IRIs. + +Use LinkML's metamodel extension mechanism to declare the distinction. An +``instantiates`` reference identifies a metaclass whose slots describe the +annotations on that schema element. ``nodeidentifier`` denotes an IRI, CURIE or +blank node; ``string`` denotes text, even when the text looks like a URL: + +.. code-block:: yaml + + id: https://example.org/model + name: model + prefixes: + ex: https://example.org/ + linkml: https://w3id.org/linkml/ + owl: http://www.w3.org/2002/07/owl# + imports: [linkml:types] + default_prefix: ex + instantiates: [ex:OntologyMetadata] + annotations: + prior_version: ex:previous + version_label: https://example.org/a-textual-label + classes: + OntologyMetadata: + attributes: + prior_version: + slot_uri: owl:priorVersion + range: nodeidentifier + version_label: + slot_uri: owl:versionInfo + range: string + +The ontology has ``owl:priorVersion `` and +``owl:versionInfo "https://example.org/a-textual-label"``. The same mechanism +works for custom properties and annotations on classes, slots, local types, +enums and non-literal permissible values. Metaclass inheritance and imported +metaclasses are supported. Tags may be slot names, CURIEs or full slot IRIs. + +The SHACL generator uses the same conversion when its existing +``--include-annotations`` option is enabled. Those annotations describe shapes; +they do not add constraints to ordinary data instances. Schema RDF and JSON-LD +serialization retain the LinkML annotation objects and their declarations; +they do not project annotations onto ontology properties as this generator does. + +Declared scalar ranges determine term representation. ``curie`` expands to an +IRI, ``nodeidentifier`` allows IRIs and blank nodes, and XSD datatypes produce +literals. In particular, ``xsd:anyURI`` alone does not mean an IRI node. Use +``nodeidentifier`` when that distinction matters; no new generator flag is +required. Language tags apply to textual literals only. + +This is serialization of declared scalar annotations, not complete metamodel +extension validation. Structured values, non-type ranges and boolean range +expressions currently raise an error rather than guessing a term. Conflicting +metaclass declarations also raise an error. Unresolved external metaclasses and +undeclared tags retain each generator's existing behavior; a declaration must +be available locally or through a schema import to determine the RDF term. +Ordinary string-valued metamodel fields such as ``license`` retain their +metamodel representation. An explicit annotation declaration is required for +an IRI-valued alternative; avoid assigning the same property in both forms +unless both RDF values are intended. + +References: `LinkML metamodel refinement +`__, +`OWL annotation values `__, +`RDF Schema resources `__, and +`DCMI license guidance +`__. + Enums and PermissibleValues ^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/packages/linkml/src/linkml/generators/common/annotations.py b/packages/linkml/src/linkml/generators/common/annotations.py new file mode 100644 index 0000000000..7da1078667 --- /dev/null +++ b/packages/linkml/src/linkml/generators/common/annotations.py @@ -0,0 +1,78 @@ +"""RDF annotation terms governed by declared LinkML metamodel extensions.""" + +from typing import Any + +from rdflib import XSD, BNode, Literal, URIRef + +from linkml_runtime.linkml_model import Element, PermissibleValue, SlotDefinition +from linkml_runtime.linkml_model.types import SHEX +from linkml_runtime.utils.schemaview import SchemaView +from linkml_runtime.utils.uri_validator import validate_uri + + +def declared_annotation( + schema: SchemaView, + element: Element | PermissibleValue, + tag: str, + value: Any, + language: str | None = None, +) -> tuple[URIRef, URIRef | BNode | Literal] | None: + """Serialize a scalar annotation using an instantiated metaclass's slot. + + Match local slot names or expanded slot URIs in the induced metaclass, + including inherited and imported definitions. Unresolved external metaclasses + and undeclared tags leave the caller's existing behavior unchanged. Conflicting + declarations fail instead of selecting a range according to iteration order. + + This is RDF serialization, not annotation constraint validation. The + metamodel-extension contract is described in LinkML specification section 5, + ``Metamodel refinement using annotations``. + """ + declarations: list[SlotDefinition] = [] + for metaclass in element.instantiates: + identifier = schema.expand_curie(metaclass) + for cls in schema.all_classes().values(): + if schema.get_uri(cls, expand=True) != identifier: + continue + for slot in schema.class_induced_slots(cls.name): + if tag == slot.name or schema.expand_curie(tag) == schema.get_uri(slot, expand=True): + declarations.append(slot) + if not declarations: + return None + terms = { + (URIRef(schema.get_uri(slot, expand=True)), _annotation_value(schema, slot, value, language)) + for slot in declarations + } + if len(terms) != 1: + raise ValueError(f"Conflicting metaclass declarations for annotation {tag!r}") + return terms.pop() + + +def _annotation_value( + schema: SchemaView, slot: SlotDefinition, value: Any, language: str | None +) -> URIRef | BNode | Literal: + """Convert a declared scalar range to its RDF term without vocabulary guessing.""" + if slot.any_of or slot.all_of or slot.exactly_one_of or slot.none_of or slot.range_expression: + raise ValueError(f"Annotation {slot.name!r} requires a single scalar range for RDF serialization") + if not isinstance(value, str | bool | int | float): + raise ValueError(f"Annotation {slot.name!r} requires a scalar RDF value") + if slot.range not in schema.all_types(): + raise ValueError(f"Annotation {slot.name!r} has unsupported non-scalar range {slot.range!r}") + typ = schema.induced_type(slot.range) + datatype = URIRef(schema.expand_curie(typ.uri)) if typ.uri else None + # The standard curie type requires expansion in RDF even though its lexical + # datatype is xsd:string. nodeidentifier explicitly denotes a non-literal; + # xsd:anyURI alone does not distinguish an IRI node from a URI literal. + curie = datatype == XSD.string and "curie" in schema.type_ancestors(slot.range) + if datatype in (SHEX.iri, SHEX.nonLiteral) or curie: + if not isinstance(value, str): + raise ValueError(f"Annotation {slot.name!r} requires a node identifier") + if datatype == SHEX.nonLiteral and value.startswith("_:") and len(value) > 2: + return BNode(value[2:]) + expanded = schema.expand_curie(value) + if not validate_uri(expanded): + raise ValueError(f"Annotation {slot.name!r} requires an absolute IRI or declared CURIE: {value!r}") + return URIRef(expanded) + if datatype in (None, XSD.string): + return Literal(value, lang=language if isinstance(value, str) else None) + return Literal(value, datatype=datatype) diff --git a/packages/linkml/src/linkml/generators/owlgen.py b/packages/linkml/src/linkml/generators/owlgen.py index 7ba15df672..5a7ffe7a55 100644 --- a/packages/linkml/src/linkml/generators/owlgen.py +++ b/packages/linkml/src/linkml/generators/owlgen.py @@ -19,6 +19,7 @@ from linkml import METAMODEL_NAMESPACE_NAME from linkml._version import __version__ +from linkml.generators.common.annotations import declared_annotation from linkml.generators.common.subproperty import is_xsd_anyuri_range from linkml.utils.deprecation import deprecation_warning from linkml.utils.generator import Generator, shared_arguments @@ -32,6 +33,7 @@ ClassDefinitionName, ClassRule, Definition, + Element, EnumDefinition, EnumDefinitionName, PermissibleValue, @@ -421,9 +423,19 @@ def add_metadata(self, e: Definition | PermissibleValue, uri: URIRef) -> None: obj = Literal(v) self.graph.add((uri, metaslot_uri, obj)) + self._add_annotations(e, uri) + + def _add_annotations(self, e: Element | PermissibleValue, uri: URIRef) -> None: + """Emit explicit annotations on any generated schema resource.""" + this_sv = self.schemaview + lang = self._resolve_language(e) for k, v in e.annotations.items(): if isinstance(v, dict) or isinstance(v, list): continue + declared = declared_annotation(this_sv, e, k, v.value, lang) + if declared is not None: + self.graph.add((uri, *declared)) + continue if ":" not in k: default_prefix = this_sv.schema.default_prefix if default_prefix in this_sv.schema.prefixes: @@ -1085,6 +1097,7 @@ def add_type(self, typ: TypeDefinition) -> None: if typ.from_schema == "https://w3id.org/linkml/types": return + self._add_annotations(typ, type_uri) if self.metaclasses: self.graph.add( ( diff --git a/packages/linkml/src/linkml/generators/shaclgen.py b/packages/linkml/src/linkml/generators/shaclgen.py index 4731b9f0b8..dfddcc367b 100644 --- a/packages/linkml/src/linkml/generators/shaclgen.py +++ b/packages/linkml/src/linkml/generators/shaclgen.py @@ -11,6 +11,7 @@ from rdflib.namespace import RDF, RDFS, SH, XSD from linkml._version import __version__ +from linkml.generators.common.annotations import declared_annotation from linkml.generators.common.subproperty import get_subproperty_values, is_uri_range from linkml.generators.shacl.shacl_data_type import ShaclDataType from linkml.generators.shacl.shacl_ifabsent_processor import ShaclIfAbsentProcessor @@ -787,6 +788,10 @@ def _add_annotations(self, func: Callable, item) -> None: if type(annotations) is JsonObj: annotations = as_dict(annotations) for a in annotations.values(): + declared = declared_annotation(sv, item, a["tag"], a["value"], self._resolve_language(item)) + if declared is not None: + func(*declared) + continue # If ':' is in the tag, treat it as a CURIE, otherwise string Literal if ":" in a["tag"]: N_predicate = URIRef(sv.expand_curie(a["tag"])) diff --git a/tests/linkml/test_generators/test_declared_annotations.py b/tests/linkml/test_generators/test_declared_annotations.py new file mode 100644 index 0000000000..697d60f19c --- /dev/null +++ b/tests/linkml/test_generators/test_declared_annotations.py @@ -0,0 +1,207 @@ +"""Declared annotation ranges determine RDF terms independently of vocabulary.""" + +from pathlib import Path + +import pytest +import yaml +from pyshacl import validate +from rdflib import RDF, SH, XSD, BNode, Graph, Literal, URIRef + +from linkml.generators.owlgen import OwlSchemaGenerator +from linkml.generators.shaclgen import ShaclGenerator + +EX = "https://example.org/" +SCHEMA = """ +id: https://example.org/model +name: model +prefixes: + ex: https://example.org/ + linkml: https://w3id.org/linkml/ +imports: [linkml:types] +default_prefix: ex +types: + Reference: + typeof: nodeidentifier + Text: + typeof: string +classes: + Metadata: + class_uri: ex:Metadata + attributes: + reference: + slot_uri: ex:reference + range: Reference + text: + slot_uri: ex:text + range: Text + Profile: + is_a: Metadata + class_uri: ex:Profile + Thing: + instantiates: [ex:Profile] + annotations: + ex:reference: ex:target + ex:text: https://example.org/literal-text +""" + + +def _graph(source: str | Path, generator: str, **kwargs: object) -> Graph: + """Exercise generated Turtle, including term preservation after parsing.""" + if generator == "owl": + output = OwlSchemaGenerator(source, **kwargs).serialize() + else: + output = ShaclGenerator(source, include_annotations=True, **kwargs).serialize() + return Graph().parse(data=output, format="turtle") + + +@pytest.mark.parametrize("generator", ["owl", "shacl"]) +@pytest.mark.parametrize("tag_form", ["local", "curie", "iri"]) +@pytest.mark.parametrize("value", ["ex:target", "https://example.net/target", "urn:example:target"]) +def test_declared_ranges(generator: str, tag_form: str, value: str) -> None: + """Inherited declarations handle arbitrary predicates, CURIEs and absolute IRIs.""" + schema = yaml.safe_load(SCHEMA) + prefix = {"local": "", "curie": "ex:", "iri": EX}[tag_form] + schema["classes"]["Thing"]["annotations"] = { + prefix + "reference": value, + prefix + "text": EX + "literal-text", + } + graph = _graph(yaml.safe_dump(schema), generator) + expected = URIRef(EX + "target" if value == "ex:target" else value) + assert list(graph.objects(None, URIRef(EX + "reference"))) == [expected] + assert list(graph.objects(None, URIRef(EX + "text"))) == [Literal(EX + "literal-text")] + + +@pytest.mark.parametrize("generator", ["owl", "shacl"]) +@pytest.mark.parametrize("owner", ["class", "slot", "type"]) +def test_metadata_owners(generator: str, owner: str) -> None: + """Every annotated shape/resource uses the same declared term conversion.""" + schema = yaml.safe_load(SCHEMA) + metadata = {"instantiates": ["ex:Profile"], "annotations": {"ex:reference": "ex:target"}} + schema["classes"]["Thing"].pop("annotations") + if owner == "class": + schema["classes"]["Thing"].update(metadata) + elif owner == "slot": + schema["classes"]["Thing"]["attributes"] = {"value": {"range": "string", **metadata}} + else: + schema["types"]["Text"].update(metadata) + schema["classes"]["Thing"]["attributes"] = {"value": {"range": "Text"}} + graph = _graph(yaml.safe_dump(schema), generator) + assert URIRef(EX + "target") in graph.objects(None, URIRef(EX + "reference")) + + +@pytest.mark.parametrize("generator", ["owl", "shacl"]) +def test_imported_metaclass(tmp_path: Path, generator: str) -> None: + """Imported definitions are resolved by class URI, including inherited slots.""" + profile = yaml.safe_load(SCHEMA) + del profile["classes"]["Thing"] + (tmp_path / "profile.yaml").write_text(yaml.safe_dump(profile)) + schema = yaml.safe_load(SCHEMA) + schema["id"] = EX + "consumer" + schema["imports"] = ["profile"] + del schema["types"] + schema["classes"] = {"Thing": schema["classes"]["Thing"]} + source = tmp_path / "consumer.yaml" + source.write_text(yaml.safe_dump(schema)) + assert URIRef(EX + "target") in _graph(source, generator).objects(None, URIRef(EX + "reference")) + + +@pytest.mark.parametrize("generator", ["owl", "shacl"]) +@pytest.mark.parametrize("value", ["plain text", "relative/path", "https://example.org/%ZZ", 42]) +def test_invalid_node_identifier_fails(generator: str, value: object) -> None: + """A declared node cannot silently become a literal or an invalid RDF IRI.""" + schema = yaml.safe_load(SCHEMA) + schema["classes"]["Thing"]["annotations"]["ex:reference"] = value + with pytest.raises(ValueError, match="requires (an absolute IRI|a node identifier)"): + _graph(yaml.safe_dump(schema), generator) + + +@pytest.mark.parametrize("generator", ["owl", "shacl"]) +def test_blank_node_and_language(generator: str) -> None: + """Explicit node identifiers allow blank nodes; only text receives a language tag.""" + schema = yaml.safe_load(SCHEMA) + schema["classes"]["Thing"]["annotations"]["ex:reference"] = "_:target" + graph = _graph(yaml.safe_dump(schema), generator, default_language="en") + assert isinstance(next(graph.objects(None, URIRef(EX + "reference"))), BNode) + assert Literal(EX + "literal-text", lang="en") in graph.objects(None, URIRef(EX + "text")) + + +@pytest.mark.parametrize("generator", ["owl", "shacl"]) +def test_conflicting_metaclasses_fail(generator: str) -> None: + """Different RDF terms cannot be selected by the order of metaclass declarations.""" + schema = yaml.safe_load(SCHEMA) + schema["classes"]["Other"] = {"attributes": {"reference": {"slot_uri": "ex:reference", "range": "string"}}} + schema["classes"]["Thing"]["instantiates"].append("ex:Other") + with pytest.raises(ValueError, match="Conflicting metaclass"): + _graph(yaml.safe_dump(schema), generator) + + +@pytest.mark.parametrize("generator", ["owl", "shacl"]) +def test_uri_literal_is_distinct_from_node(generator: str) -> None: + """The xsd:anyURI datatype does not by itself require an IRI node.""" + schema = yaml.safe_load(SCHEMA) + schema["types"]["Text"]["uri"] = "xsd:anyURI" + graph = _graph(yaml.safe_dump(schema), generator) + assert Literal(EX + "literal-text", datatype=XSD.anyURI) in graph.objects(None, URIRef(EX + "text")) + + +@pytest.mark.parametrize("generator", ["owl", "shacl"]) +def test_curie_range_requires_expansion(generator: str) -> None: + """The standard curie type requires expansion in RDF despite its string datatype.""" + schema = yaml.safe_load(SCHEMA) + schema["types"]["Reference"]["typeof"] = "curie" + graph = _graph(yaml.safe_dump(schema), generator) + assert URIRef(EX + "target") in graph.objects(None, URIRef(EX + "reference")) + + +@pytest.mark.parametrize("generator", ["owl", "shacl"]) +@pytest.mark.parametrize("unsupported", ["structured", "class", "union"]) +def test_unsupported_declared_representation_fails(generator: str, unsupported: str) -> None: + """Unsupported values and mixed range expressions are not silently stringified.""" + schema = yaml.safe_load(SCHEMA) + slot = schema["classes"]["Metadata"]["attributes"]["reference"] + if unsupported == "structured": + schema["classes"]["Thing"]["annotations"]["ex:reference"] = {"value": {"nested": "value"}} + elif unsupported == "class": + slot["range"] = "Thing" + else: + slot["any_of"] = [{"range": "nodeidentifier"}, {"range": "string"}] + with pytest.raises(ValueError, match="scalar"): + _graph(yaml.safe_dump(schema), generator) + + +def test_untyped_metadata_preserves_literal_values() -> None: + """OWL does not infer RDF node kinds from familiar vocabulary names or URL text.""" + schema = yaml.safe_load(SCHEMA) + schema["prefixes"]["dcterms"] = "http://purl.org/dc/terms/" + schema["license"] = "https://example.org/license" + schema["annotations"] = {"dcterms:license": "SPDX:MIT"} + graph = _graph(yaml.safe_dump(schema), "owl") + predicate = URIRef("http://purl.org/dc/terms/license") + assert set(graph.objects(URIRef(EX + "model"), predicate)) == { + Literal("https://example.org/license"), + Literal("SPDX:MIT"), + } + + +def test_owl_header_enum_and_permissible_value() -> None: + """OWL annotation declarations also apply to the ontology and vocabulary resources.""" + schema = yaml.safe_load(SCHEMA) + metadata = {"instantiates": ["ex:Profile"], "annotations": {"ex:reference": "ex:target"}} + schema.update(metadata) + schema["enums"] = {"Choice": {**metadata, "permissible_values": {"A": {"meaning": "ex:A", **metadata}}}} + graph = _graph(yaml.safe_dump(schema), "owl") + for subject in [EX + "model", EX + "Choice", EX + "A"]: + assert (URIRef(subject), URIRef(EX + "reference"), URIRef(EX + "target")) in graph + + +def test_annotations_do_not_constrain_data() -> None: + """Metadata appears on a shape without becoming a required data property.""" + graph = _graph(SCHEMA, "shacl") + shape = next(graph.subjects(SH.targetClass, URIRef(EX + "Thing"))) + assert (shape, URIRef(EX + "reference"), URIRef(EX + "target")) in graph + for prop in graph.objects(shape, SH.property): + assert (prop, SH.path, URIRef(EX + "reference")) not in graph + assert (shape, RDF.type, SH.NodeShape) in graph + data = Graph() + data.add((URIRef(EX + "instance"), RDF.type, URIRef(EX + "Thing"))) + assert validate(data, shacl_graph=graph, meta_shacl=True)[0]