diff --git a/docs/examples/owl-dcmi-agent-annotations.yaml b/docs/examples/owl-dcmi-agent-annotations.yaml new file mode 100644 index 0000000000..d2156d223c --- /dev/null +++ b/docs/examples/owl-dcmi-agent-annotations.yaml @@ -0,0 +1,26 @@ +id: https://example.org/model +name: model +prefixes: + ex: https://example.org/ + linkml: https://w3id.org/linkml/ + dcterms: http://purl.org/dc/terms/ + orcid: https://orcid.org/ + owl: http://www.w3.org/2002/07/owl# +imports: [linkml:types] +default_prefix: ex +instantiates: [ex:AgentMetadata] +annotations: + creator: orcid:0000-0002-1825-0097 + rights: Copyright 2026 The Example Team +classes: + AgentMetadata: + description: Declares the agent metadata that a schema may carry as annotations. + attributes: + creator: + slot_uri: dcterms:creator + range: nodeidentifier + implements: [owl:AnnotationProperty] + rights: + slot_uri: dcterms:rights + range: string + implements: [owl:AnnotationProperty] diff --git a/docs/generators/owl.rst b/docs/generators/owl.rst index 9ce2ee0994..a0ed1edeb3 100644 --- a/docs/generators/owl.rst +++ b/docs/generators/owl.rst @@ -70,6 +70,25 @@ Mapping .. note:: The current default settings for ``metaclasses`` and ``type-objects`` may change in the future +Annotation values +^^^^^^^^^^^^^^^^^ + +Native metadata takes the RDF term of its metamodel range: ``see_also`` and ``source`` +values are IRIs, ``license`` and ``description`` values are strings, and ``created_on`` +is an ``xsd:dateTime`` literal. A link value that is neither an IRI nor a CURIE, such as +a bare element name in ``see_also``, is written as a plain literal, with a warning. An annotation +whose tag a metaclass declares through ``instantiates`` takes the term of that slot's +range in the same way (see :ref:`annotations-in-rdf`); any other annotation value is a +string. + +.. literalinclude:: ../examples/owl-dcmi-agent-annotations.yaml + :language: yaml + +The ontology header then carries ``dcterms:creator `` +and ``dcterms:rights "Copyright 2026 The Example Team"``. ``implements: [owl:AnnotationProperty]`` +declares the two predicates as annotation properties; the restrictions that the metaclass's +own slots produce are a matter of the property role, not of the value. + Enums and PermissibleValues ^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/docs/schemas/annotations.md b/docs/schemas/annotations.md index b29d56e231..a22892ac41 100644 --- a/docs/schemas/annotations.md +++ b/docs/schemas/annotations.md @@ -130,6 +130,38 @@ first-class slots, and instantiates value was a mixin. However, this is not yet supported in the current LinkML validator, so using `instantiates` indicates your intent, but does not yet enforce validation. +(annotations-in-rdf)= +## Annotation values in RDF + +The declared range also decides how gen-owl and gen-shacl (with `--include-annotations`) write an +annotation value, by the rule that applies to slot values (see {ref}`links-and-uri-references`): a +`nodeidentifier` or `uri` value becomes an IRI, or a blank node for a `_:` label; a `string` value stays +text and carries the language of `--default-language`; a `curie` value stays a string; and the value of +an enum-ranged slot is written as the permissible value's text. + +```yaml +classes: + Reviewable: + class_uri: mymetamodel:Reviewable + attributes: + review: + range: string + reviewer: + range: nodeidentifier + Person: + instantiates: + - mymetamodel:Reviewable + annotations: + review: A very useful class that is well defined + reviewer: orcid:0000-0002-1825-0097 +``` + +gen-owl then writes `mymetamodel:review "A very useful class that is well defined"` and +`mymetamodel:reviewer ` on the class. A value that cannot be a +term of the declared kind, such as a relative reference for a `uri` slot, is an error. Without a +declaration the generators keep their earlier behaviour: gen-owl writes every annotation value as a +string, and gen-shacl writes a value containing a colon as an IRI. + ## Other uses of instantiates The `instantiates` slot can also be used constrain rather than extend. 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..702ace7ef8 --- /dev/null +++ b/packages/linkml/src/linkml/generators/common/annotations.py @@ -0,0 +1,120 @@ +"""RDF terms of annotation values that a metamodel extension declares. + +A schema element can ``instantiates`` a metaclass whose slots describe the +element's annotations (LinkML specification, part 5, "Metamodel refinement using +annotations"). Such a slot gives an annotation value its RDF term by the rule +that applies to slot values (:mod:`linkml_runtime.utils.rdf_terms`): a value of +``nodeidentifier`` or ``uri`` is a node, a value of ``string`` is text, a value +of a type derived from ``string`` with ``uri: xsd:anyURI`` is an ``xsd:anyURI`` +literal, and so on. OWL 2 admits IRIs, anonymous individuals and literals as +annotation values (OWL 2 Structural Specification §10.1), so no annotation value +needs a term of another kind. + +>>> from linkml_runtime.utils.schemaview import SchemaView +>>> sv = SchemaView(''' +... id: https://example.org/m +... name: m +... prefixes: {ex: https://example.org/m/, linkml: https://w3id.org/linkml/} +... default_prefix: ex +... imports: [linkml:types] +... classes: +... Reviewable: +... attributes: +... reviewer: {range: nodeidentifier} +... review: {range: string} +... Person: +... instantiates: [ex:Reviewable] +... annotations: {reviewer: ex:alice, review: well defined} +... ''') +>>> person = sv.get_class("Person") +>>> declared_annotation(sv, person, "reviewer", "ex:alice") +DeclaredAnnotation(predicate=rdflib.term.URIRef('https://example.org/m/reviewer'), value=rdflib.term.URIRef('https://example.org/m/alice')) +>>> declared_annotation(sv, person, "review", "well defined", language="en").value +rdflib.term.Literal('well defined', lang='en') +>>> declared_annotation(sv, person, "undeclared", "x") is None +True +""" + +from dataclasses import dataclass +from typing import Any + +from rdflib import BNode, Literal, URIRef + +from linkml_runtime.linkml_model.meta import Element, PermissibleValue, SlotDefinition +from linkml_runtime.utils.rdf_terms import value_term +from linkml_runtime.utils.schemaview import SchemaView + + +@dataclass(frozen=True) +class DeclaredAnnotation: + """The RDF predicate and value of an annotation that a metaclass slot declares.""" + + predicate: URIRef + value: URIRef | BNode | Literal + + +def declaring_slots(sv: SchemaView, element: Element | PermissibleValue, tag: str) -> list[SlotDefinition]: + """Return the slots of the metaclasses that *element* instantiates which declare annotation *tag*. + + A metaclass is referenced by its class URI. A tag matches a slot by name or, + as a CURIE or IRI, by the slot's URI. Inherited and imported slots count. + """ + expanded_tag = sv.expand_curie(tag) + slots: list[SlotDefinition] = [] + for reference in element.instantiates: + metaclass_uri = sv.expand_curie(reference) + for cls in sv.all_classes().values(): + if sv.get_uri(cls, expand=True) != metaclass_uri: + continue + for slot in sv.class_induced_slots(cls.name): + if slot.name == tag or sv.get_uri(slot, expand=True) == expanded_tag: + slots.append(slot) + return slots + + +def annotation_term( + sv: SchemaView, slot: SlotDefinition, value: Any, language: str | None = None +) -> URIRef | BNode | Literal | None: + """Return the RDF term of annotation *value* according to the range of the declaring *slot*. + + A type range gives the term of a slot value of that type; text, which is a + plain literal of a type derived from ``string``, carries *language* when one is + set. An enum range gives the permissible value's text as a plain literal, since + it is an identifier rather than text. Any other range, such as a class, declares + no scalar term, and the result is ``None``. + + :raises ValueError: if *value* cannot be a term of the declared kind, for + example a relative reference for a ``uri`` slot + """ + if slot.range in sv.all_types(): + term = value_term(sv, slot.range, value) + is_text = isinstance(term, Literal) and term.datatype is None and "string" in sv.type_ancestors(slot.range) + return Literal(value, lang=language) if language and is_text else term + if slot.range in sv.all_enums(): + return Literal(str(value)) + return None + + +def declared_annotation( + sv: SchemaView, element: Element | PermissibleValue, tag: str, value: Any, language: str | None = None +) -> DeclaredAnnotation | None: + """Return the predicate and term of annotation *tag* on *element* if a metaclass declares it. + + The result is ``None`` when no metaclass of *element* declares the tag, when the + value is not a scalar, or when the declaring slot has no scalar range; the + caller then keeps its undeclared-annotation behaviour. + + :raises ValueError: if the metaclasses declare the tag with different terms, or + if the value cannot be a term of the declared kind + """ + if not isinstance(value, str | bool | int | float): + return None + declared: set[DeclaredAnnotation] = set() + for slot in declaring_slots(sv, element, tag): + term = annotation_term(sv, slot, value, language) + if term is not None: + declared.add(DeclaredAnnotation(URIRef(sv.get_uri(slot, expand=True)), term)) + if len(declared) > 1: + metaclasses = ", ".join(element.instantiates) + raise ValueError(f"The metaclasses {metaclasses} declare annotation {tag!r} with different terms") + return declared.pop() if declared else None diff --git a/packages/linkml/src/linkml/generators/owlgen.py b/packages/linkml/src/linkml/generators/owlgen.py index 5580a7dcd0..e7a9911bea 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.utils.deprecation import XSD_ANYURI_AS_IRI_OPTION, deprecation_warning from linkml.utils.generator import Generator, shared_arguments from linkml.utils.language_tags import LanguageTagResolver @@ -43,7 +44,7 @@ from linkml_runtime.utils.formatutils import camelcase, underscore from linkml_runtime.utils.introspection import package_schemaview from linkml_runtime.utils.rdf_canonicalize import canonicalize_rdf_graph -from linkml_runtime.utils.rdf_terms import type_term +from linkml_runtime.utils.rdf_terms import link_iri, type_term, value_term logger = logging.getLogger(__name__) @@ -383,14 +384,7 @@ def add_metadata(self, e: Definition | PermissibleValue, uri: URIRef) -> None: metaslot_value = [metaslot_value] for v in metaslot_value: if metaslot_range in msv.all_types(): - if metaslot_range == "uri": - obj = URIRef(v) - elif metaslot_range == "uriorcurie": - obj = URIRef(this_sv.expand_curie(v)) - elif metaslot_range in self._LANGUAGE_TAGGABLE_RANGES and lang: - obj = Literal(v, lang=lang) - else: - obj = Literal(v) + obj = self._metaslot_term(metaslot_range, v, lang, f"{metaslot_name} of {uri}") elif metaslot_range in msv.all_subsets(): obj = Literal(v) # TODO elif metaslot_range in msv.all_classes(): @@ -415,6 +409,10 @@ def add_metadata(self, e: Definition | PermissibleValue, uri: URIRef) -> None: 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.predicate, declared.value)) + continue if ":" not in k: default_prefix = this_sv.schema.default_prefix if default_prefix in this_sv.schema.prefixes: @@ -432,6 +430,32 @@ def add_metadata(self, e: Definition | PermissibleValue, uri: URIRef) -> None: obj = Literal(v.value) self.graph.add((uri, URIRef(k_uri), obj)) + def _metaslot_term( + self, range_name: str, value: Any, language: str | None, origin: str + ) -> URIRef | BNode | Literal: + """Return the RDF term of a native metadata value according to the metamodel slot's range. + + The term kind follows :func:`~linkml_runtime.utils.rdf_terms.value_term`, so + ``see_also`` and ``source`` values are IRIs and ``license`` values are + strings. A link's CURIE is expanded with the schema's prefixes, then with the + metamodel's. Human-readable text carries the resolved language. A link value + that is neither an IRI nor a CURIE, such as a bare element name in + ``see_also``, cannot be resolved; it is written as a plain literal, with a + warning, since a relative IRI would make the whole graph non-standard RDF + and OWL 2 admits a literal as an annotation value (Structural Specification + §10.1). + """ + msv = self.metamodel_schemaview + if range_name in self._LANGUAGE_TAGGABLE_RANGES and language: + return Literal(value, lang=language) + if not type_term(msv, range_name).kind.is_node: + return value_term(msv, range_name, value) + link = self.schemaview.expand_curie(str(value)) + if link_iri(msv, link) is None and not link.startswith("_:"): + logger.warning(f"{origin}: {value!r} is neither an IRI nor a CURIE; written as a literal") + return Literal(str(value)) + return value_term(msv, range_name, link) + def add_class(self, cls: ClassDefinition) -> None: """ Each ClassDefinition is represented as an OWL class. diff --git a/packages/linkml/src/linkml/generators/shaclgen.py b/packages/linkml/src/linkml/generators/shaclgen.py index edaf6f7e38..bc49e61d3c 100644 --- a/packages/linkml/src/linkml/generators/shaclgen.py +++ b/packages/linkml/src/linkml/generators/shaclgen.py @@ -12,6 +12,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 from linkml.generators.shacl.shacl_data_type import ShaclDataType from linkml.generators.shacl.shacl_ifabsent_processor import ShaclIfAbsentProcessor @@ -809,6 +810,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.predicate, declared.value) + 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/packages/linkml_runtime/src/linkml_runtime/utils/rdf_terms.py b/packages/linkml_runtime/src/linkml_runtime/utils/rdf_terms.py index 73e8411234..90da49cb0a 100644 --- a/packages/linkml_runtime/src/linkml_runtime/utils/rdf_terms.py +++ b/packages/linkml_runtime/src/linkml_runtime/utils/rdf_terms.py @@ -45,6 +45,8 @@ rdflib.term.URIRef('https://example.org/t/me.jpg') >>> value_term(sv, "FilePath", "./docs/cv.pdf") rdflib.term.Literal('./docs/cv.pdf', datatype=rdflib.term.URIRef('http://www.w3.org/2001/XMLSchema#anyURI')) +>>> link_iri(sv, "ex:me.jpg"), link_iri(sv, "./docs/cv.pdf") +('https://example.org/t/me.jpg', None) """ import re @@ -174,6 +176,17 @@ def type_term(sv: SchemaView, type_name: str) -> TypeTerm: return TypeTerm(TermKind.LITERAL, datatype) +def link_iri(sv: SchemaView, value: str) -> str | None: + """Return the absolute IRI that link *value* denotes, or ``None`` if it is a relative reference. + + A CURIE is expanded with the schema's prefixes. A value with an undeclared prefix + passes as an absolute IRI of that scheme, as in JSON-LD; one without a scheme + (RFC 3986 §3.1) is a relative reference. + """ + iri = sv.expand_curie(value) + return iri if _SCHEME.match(iri) else None + + def value_term(sv: SchemaView, type_name: str, value: Any) -> Node: """Return the RDF term for *value* of the type *type_name*. @@ -197,8 +210,8 @@ def value_term(sv: SchemaView, type_name: str, value: Any) -> Node: raise ValueError(f"Value {text!r} of type {type_name!r} must be a blank node label such as '_:b1'") if text.startswith("_:"): raise ValueError(f"Value {text!r} of type {type_name!r} is a blank node, but values of this type are IRIs") - iri = sv.expand_curie(text) - if not _SCHEME.match(iri): + iri = link_iri(sv, text) + if iri is None: raise ValueError( f"Value {text!r} of type {type_name!r} is a relative reference, but values of this type are " "IRIs, which must be absolute. Use a type derived from string with uri: xsd:anyURI to keep " 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..34b618b672 --- /dev/null +++ b/tests/linkml/test_generators/test_declared_annotations.py @@ -0,0 +1,259 @@ +"""Annotation values declared through a metamodel extension take the RDF term of their declared range. + +The rule is ``linkml.generators.common.annotations``: a slot of a metaclass that a +schema element ``instantiates`` gives the annotation value the term a slot value of +that range gets (``linkml_runtime.utils.rdf_terms``). gen-owl and gen-shacl share it, +and gen-owl applies the same rule to native metadata such as ``see_also``. +""" + +import warnings +from pathlib import Path + +import pytest +import yaml +from pyshacl import validate +from rdflib import DCTERMS, RDF, RDFS, SH, XSD, BNode, Graph, Literal, URIRef + +from linkml.generators.owlgen import OwlSchemaGenerator +from linkml.generators.shaclgen import ShaclGenerator +from linkml_runtime.utils.rdf_canonicalize import RDFCanonicalizationWarning + +EX = "https://example.org/" +EXAMPLE = Path(__file__).resolve().parents[3] / "docs/examples/owl-dcmi-agent-annotations.yaml" + +SCHEMA = """ +id: https://example.org/model +name: model +prefixes: + ex: https://example.org/ + linkml: https://w3id.org/linkml/ +imports: [linkml:types] +default_prefix: ex +default_range: string +types: + FilePath: + typeof: string + uri: xsd:anyURI +enums: + Status: + permissible_values: {draft: {}, final: {}} +classes: + Metadata: + attributes: + reviewer: {range: nodeidentifier} + homepage: {range: uri} + file_path: {range: FilePath} + code: {range: curie} + status: {range: Status} + review: {range: string} + note: {} + Profile: + is_a: Metadata + class_uri: ex:Profile + Thing: + instantiates: [ex:Profile] + annotations: + reviewer: ex:alice + homepage: https://alice.example.org/ + file_path: ./docs/cv.pdf + code: ex:c1 + status: draft + review: well defined + note: free text +""" + +# tag -> the term its declared range gives the value (with default_language "en") +EXPECTED = { + "reviewer": URIRef(EX + "alice"), + "homepage": URIRef("https://alice.example.org/"), + "file_path": Literal("./docs/cv.pdf", datatype=XSD.anyURI), + "code": Literal("ex:c1"), + "status": Literal("draft"), + "review": Literal("well defined", lang="en"), + "note": Literal("free text", lang="en"), +} + + +def _graph(source: str | Path, generator: str, **kwargs) -> Graph: + """The generated OWL ontology or SHACL shapes, parsed back from Turtle.""" + if generator == "owl": + text = OwlSchemaGenerator(source, type_objects=False, metaclasses=False, default_language="en", **kwargs) + else: + text = ShaclGenerator(source, include_annotations=True, default_language="en", **kwargs) + return Graph().parse(data=text.serialize(), format="turtle") + + +@pytest.mark.parametrize("generator", ["owl", "shacl"]) +@pytest.mark.parametrize("tag", EXPECTED) +def test_declared_range_gives_the_term(generator, tag): + """Nodes, typed literals, strings and enum values follow the metaclass slot's range. + + The metaclass needs no ``implements`` role for this, as in the annotations guide. + """ + graph = _graph(SCHEMA, generator) + assert set(graph.objects(URIRef(EX + "Thing"), URIRef(EX + tag))) == {EXPECTED[tag]} + + +@pytest.mark.parametrize("generator", ["owl", "shacl"]) +@pytest.mark.parametrize("form", ["name", "curie", "iri"]) +def test_tag_as_slot_name_curie_or_iri(generator, form): + schema = yaml.safe_load(SCHEMA) + prefix = {"name": "", "curie": "ex:", "iri": EX}[form] + schema["classes"]["Thing"]["annotations"] = {prefix + "reviewer": "ex:alice"} + graph = _graph(yaml.safe_dump(schema), generator) + assert set(graph.objects(URIRef(EX + "Thing"), URIRef(EX + "reviewer"))) == {URIRef(EX + "alice")} + + +@pytest.mark.parametrize( + "generator,owner", + [ + ("owl", "class"), + ("owl", "slot"), + ("owl", "enum"), + ("owl", "permissible_value"), + ("shacl", "slot"), + ("shacl", "type"), + ], +) +def test_annotated_elements(generator, owner): + """Every element that carries annotations in the output uses the same rule.""" + schema = yaml.safe_load(SCHEMA) + metadata = {"instantiates": ["ex:Profile"], "annotations": {"reviewer": "ex:alice"}} + schema["classes"]["Thing"].pop("annotations") + if owner == "class": + schema["classes"]["Thing"].update(metadata) + elif owner == "slot": + schema["classes"]["Thing"]["attributes"] = {"value": {"range": "string", **metadata}} + elif owner == "enum": + schema["enums"]["Status"].update(metadata) + elif owner == "permissible_value": + schema["enums"]["Status"]["permissible_values"]["draft"] = {"meaning": "ex:Draft", **metadata} + else: + schema["types"]["FilePath"].update(metadata) + schema["classes"]["Thing"]["attributes"] = {"value": {"range": "FilePath"}} + graph = _graph(yaml.safe_dump(schema), generator) + assert URIRef(EX + "alice") in graph.objects(None, URIRef(EX + "reviewer")) + + +@pytest.mark.parametrize("generator", ["owl", "shacl"]) +def test_imported_metaclass(tmp_path, generator): + """A metaclass from an imported schema is found by its class URI, with its inherited slots.""" + profile = yaml.safe_load(SCHEMA) + del profile["classes"]["Thing"] + (tmp_path / "profile.yaml").write_text(yaml.safe_dump(profile), encoding="utf-8") + consumer = yaml.safe_load(SCHEMA) + consumer.update({"id": EX + "consumer", "imports": ["profile"], "classes": {"Thing": consumer["classes"]["Thing"]}}) + del consumer["types"], consumer["enums"] + source = tmp_path / "consumer.yaml" + source.write_text(yaml.safe_dump(consumer), encoding="utf-8") + assert URIRef(EX + "alice") in _graph(source, generator).objects(None, URIRef(EX + "reviewer")) + + +@pytest.mark.parametrize("generator", ["owl", "shacl"]) +def test_blank_node_label_for_a_node_type(generator): + schema = yaml.safe_load(SCHEMA) + schema["classes"]["Thing"]["annotations"]["reviewer"] = "_:anonymous" + graph = _graph(yaml.safe_dump(schema), generator) + assert isinstance(next(graph.objects(URIRef(EX + "Thing"), URIRef(EX + "reviewer"))), BNode) + + +@pytest.mark.parametrize( + "generator,tag,expected", [("owl", "other", Literal("ex:x", lang="en")), ("shacl", "ex:other", URIRef(EX + "x"))] +) +def test_undeclared_tag_keeps_the_generator_behaviour(generator, tag, expected): + """Without a declaration, gen-owl writes text and gen-shacl guesses an IRI from the colon, as before.""" + schema = yaml.safe_load(SCHEMA) + schema["classes"]["Thing"]["annotations"][tag] = "ex:x" + graph = _graph(yaml.safe_dump(schema), generator) + assert set(graph.objects(URIRef(EX + "Thing"), URIRef(EX + "other"))) == {expected} + + +@pytest.mark.parametrize("generator,owner", [("owl", "class"), ("shacl", "class"), ("owl", "permissible_value")]) +def test_conflicting_declarations_fail(generator, owner): + schema = yaml.safe_load(SCHEMA) + schema["classes"]["Other"] = {"attributes": {"reviewer": {"range": "string"}}} + metadata = {"instantiates": ["ex:Profile", "ex:Other"], "annotations": {"reviewer": "ex:alice"}} + if owner == "class": + schema["classes"]["Thing"].update(metadata) + else: + schema["enums"]["Status"]["permissible_values"]["draft"] = {"meaning": "ex:Draft", **metadata} + with pytest.raises(ValueError, match="ex:Other.*different terms"): + _graph(yaml.safe_dump(schema), generator) + + +@pytest.mark.parametrize("generator", ["owl", "shacl"]) +@pytest.mark.parametrize("value,message", [("./home.html", "relative reference"), ("_:b1", "blank node")]) +def test_value_of_the_wrong_kind_fails(generator, value, message): + """A ``uri`` value must be an absolute IRI (RDF 1.1 Concepts §3.2), not text or a blank node.""" + schema = yaml.safe_load(SCHEMA) + schema["classes"]["Thing"]["annotations"]["homepage"] = value + with pytest.raises(ValueError, match=message): + _graph(yaml.safe_dump(schema), generator) + + +def test_native_metadata_follows_the_metamodel_range(): + """``see_also`` and ``source`` are ``uriorcurie`` (IRIs); ``license`` is a string; ``created_on`` a datetime.""" + schema = yaml.safe_load(SCHEMA) + schema.update( + { + "see_also": ["ex:docs"], + "source": "https://example.org/source", + "license": "https://creativecommons.org/licenses/by/4.0/", + "created_on": "2026-10-21T10:00:00", + } + ) + graph = _graph(yaml.safe_dump(schema), "owl") + ontology = URIRef(EX + "model") + assert set(graph.objects(ontology, RDFS.seeAlso)) == {URIRef(EX + "docs")} + assert set(graph.objects(ontology, DCTERMS.source)) == {URIRef("https://example.org/source")} + assert set(graph.objects(ontology, DCTERMS.license)) == {Literal(schema["license"], lang="en")} + assert set(graph.objects(ontology, URIRef("http://purl.org/pav/createdOn"))) == { + Literal("2026-10-21T10:00:00", datatype=XSD.dateTime) + } + + +def test_native_link_uses_the_schema_prefixes_without_a_types_import(): + """A schema that imports nothing still expands its own CURIEs; the term kind comes from the metamodel.""" + schema = { + "id": EX + "bare", + "name": "bare", + "prefixes": {"ex": EX, "linkml": "https://w3id.org/linkml/"}, + "default_prefix": "ex", + "classes": {"Thing": {"see_also": ["ex:docs", "skos:Concept"]}}, + } + graph = _graph(yaml.safe_dump(schema), "owl") + assert set(graph.objects(URIRef(EX + "Thing"), RDFS.seeAlso)) == { + URIRef(EX + "docs"), + URIRef("http://www.w3.org/2004/02/skos/core#Concept"), + } + + +def test_native_link_that_is_not_a_link_is_kept_as_a_literal(caplog): + """A bare element name in ``see_also`` cannot be resolved; it is kept as text, so the output stays standard RDF.""" + schema = yaml.safe_load(SCHEMA) + schema["classes"]["Thing"]["see_also"] = ["implements"] + with warnings.catch_warnings(): + warnings.simplefilter("error", RDFCanonicalizationWarning) + output = OwlSchemaGenerator(yaml.safe_dump(schema), type_objects=False, metaclasses=False).serialize() + graph = Graph().parse(data=output, format="turtle") + assert set(graph.objects(URIRef(EX + "Thing"), RDFS.seeAlso)) == {Literal("implements")} + assert "see_also of https://example.org/Thing: 'implements' is neither an IRI nor a CURIE" in caplog.text + + +def test_documented_dcmi_example(): + """The example in the OWL documentation names its creator by IRI and holds its rights as text.""" + graph = _graph(EXAMPLE, "owl") + ontology = URIRef(EX + "model") + assert set(graph.objects(ontology, DCTERMS.creator)) == {URIRef("https://orcid.org/0000-0002-1825-0097")} + assert set(graph.objects(ontology, DCTERMS.rights)) == {Literal("Copyright 2026 The Example Team", lang="en")} + + +def test_shacl_annotations_do_not_constrain_data(): + """An annotation on a node shape is metadata; instances need no such property.""" + shapes = _graph(SCHEMA, "shacl") + shape = next(shapes.subjects(SH.targetClass, URIRef(EX + "Thing"))) + assert (shape, URIRef(EX + "reviewer"), URIRef(EX + "alice")) in shapes + data = Graph() + data.add((URIRef(EX + "instance"), RDF.type, URIRef(EX + "Thing"))) + conforms, _, report = validate(data, shacl_graph=shapes) + assert conforms, report