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
26 changes: 26 additions & 0 deletions docs/examples/owl-dcmi-agent-annotations.yaml
Original file line number Diff line number Diff line change
@@ -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]
19 changes: 19 additions & 0 deletions docs/generators/owl.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://orcid.org/0000-0002-1825-0097>``
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
^^^^^^^^^^^^^^^^^^^^^^^^^^^

Expand Down
32 changes: 32 additions & 0 deletions docs/schemas/annotations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://orcid.org/0000-0002-1825-0097>` 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.
Expand Down
120 changes: 120 additions & 0 deletions packages/linkml/src/linkml/generators/common/annotations.py
Original file line number Diff line number Diff line change
@@ -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
42 changes: 33 additions & 9 deletions packages/linkml/src/linkml/generators/owlgen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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__)

Expand Down Expand Up @@ -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():
Expand All @@ -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:
Expand All @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions packages/linkml/src/linkml/generators/shaclgen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"]))
Expand Down
17 changes: 15 additions & 2 deletions packages/linkml_runtime/src/linkml_runtime/utils/rdf_terms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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*.

Expand All @@ -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 "
Expand Down
Loading
Loading