Skip to content
Closed
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
46 changes: 46 additions & 0 deletions .github/workflows/owl-annotation-consumer.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: OWL annotation consumer

on:
pull_request:
push:
branches: [main]
merge_group:
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
owlapi:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v10.2.0
with:
version: "0.11.21"
enable-cache: true
- uses: actions/setup-python@v7.0.0
with:
python-version: "3.12"
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "21"
cache: maven
cache-dependency-path: tests/linkml/test_generators/input/owl_annotation_properties/pom.xml
- name: Install Python dependencies
run: uv sync --all-groups
- name: Resolve the pinned OWLAPI dependency tree once
run: >-
mvn --batch-mode
-f tests/linkml/test_generators/input/owl_annotation_properties/pom.xml
org.apache.maven.plugins:maven-dependency-plugin:3.8.1:build-classpath
-Dmdep.outputFile=${{ runner.temp }}/owlapi-classpath.txt
- name: Require profile validity and annotation retention on round-trip
env:
LINKML_REQUIRE_OWLAPI: "1"
LINKML_OWLAPI_CLASSPATH_FILE: ${{ runner.temp }}/owlapi-classpath.txt
run: >-
uv run pytest tests/linkml/test_generators/test_owl_annotation_properties.py
-q -m integration
25 changes: 25 additions & 0 deletions docs/examples/owl-annotation-properties.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
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:
reference: ex:target
text: https://example.org/a-textual-label
classes:
OntologyMetadata:
attributes:
reference:
slot_uri: ex:reference
range: nodeidentifier
implements: [owl:AnnotationProperty]
required: true
text:
slot_uri: ex:text
range: string
implements: [owl:AnnotationProperty]
required: true
39 changes: 39 additions & 0 deletions docs/generators/jsonld-context.rst
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,45 @@ to gen-prefix-map:

gen-prefix-map --flatprefixes personinfo.yaml > personinfo.prefixmap.json

URIs as IRIs or as literals
---------------------------

By default a ``uri`` or ``uriorcurie`` slot is coerced to ``xsd:anyURI``, a typed
literal. ``--xsd-anyuri-as-iri`` coerces it to ``@id`` instead, so the value becomes an
IRI node, matching the ``sh:nodeKind sh:IRI`` the SHACL generator emits. The OWL
generator accepts the same flag.

The flag applies to ``uri``, ``uriorcurie`` and their derived types when the
effective datatype remains ``xsd:anyURI``. Datatype IRIs are expanded before
comparison, so full IRIs and alternative prefixes behave identically. A type
derived from ``string`` that declares ``uri: xsd:anyURI`` stays a typed literal with or
without it. Use one for a URI reference that is data rather than a link, such as a file
path that may be relative: as ``@id`` it would be resolved against the document base.

.. code-block:: yaml

types:
FilePath:
typeof: string
uri: xsd:anyURI # always "@type": "xsd:anyURI", sh:datatype xsd:anyURI
slots:
homepage:
range: uri # "@id" with --xsd-anyuri-as-iri
file_path:
range: FilePath

The RDF dumper, SHACL and ShEx generators distinguish the same URI family from
literal-valued types. An explicit datatype override such as ``uri: xsd:string``
remains literal-valued, including when the type inherits from ``uri``.

`XSD anyURI <https://www.w3.org/TR/xmlschema11-2/#anyURI>`__ admits relative URI
references as literal values. This differs from an RDF IRI node: `JSON-LD type
coercion <https://www.w3.org/TR/json-ld11/#type-coercion>`__ with ``@id`` interprets
strings as identifiers and resolves relative references against the base IRI.
The existing option selects the JSON-LD/OWL representation of LinkML's URI
family; it is not needed to preserve a deliberately literal-valued type. No
additional option is introduced.


Docs
----
Expand Down
123 changes: 123 additions & 0 deletions docs/generators/owl.rst
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,129 @@ 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.
Independently, ``implements: [owl:AnnotationProperty]`` selects the existing
OWL annotation-property projection for a slot:

.. literalinclude:: ../examples/owl-annotation-properties.yaml
:language: yaml

The ontology has ``ex:reference <https://example.org/target>`` and
``ex:text "https://example.org/a-textual-label"``, with both predicates declared
as annotation properties. 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.
Class inheritance, mixins and slot usage use effective slot declarations.
``implements`` itself is not inherited through a global slot's ``is_a``;
declare the annotation-property role on both members of a global subproperty
relationship.
OWL's nine built-in annotation-property IRIs, including ``rdfs:label``,
``owl:priorVersion`` and ``owl:versionInfo``, have their specification-defined
role automatically when used as actual output predicate IRIs. An explicit
object/datatype role on those IRIs is rejected. With native URIs, a locally
named property mapped to a built-in can still be an ordinary logical property;
the local output IRI determines its role.
Declared annotations accept full predicate IRIs. Undeclared tags retain the
existing CURIE/name fallback; URL-looking values are never promoted to nodes.

``instantiates`` supplies metadata declarations; it does not automatically
convert every slot of a metadata class to an OWL annotation property. The
generator declares predicates used for annotations and rejects collisions
between annotation and logical property roles, and contradictory explicit
roles at the same output IRI, including available imported definitions.
Existing inferred object/datatype range ambiguities are outside this correction.
Undeclared LinkML annotations and native metadata on already logical schema predicates retain
their existing RDF metadata projection and literal values; they do not request
an OWL annotation-property role. Declared metaclass annotations and explicit
annotation-property mappings still reject logical-role collisions. This
compatibility behavior does not establish OWL 2 DL conformance for legacy RDF.
The native-URI option still selects
local property IRIs; mapped annotation predicates can have different IRIs.

Annotation properties have no OWL logical constraint semantics. Ordinary
LinkML range, requiredness, cardinality and value constraints on these slots
remain available to instance validators, but their OWL annotation projection
omits logical restrictions. LinkML ``identifier`` and singular ``key`` flags
remain available for identity, inlining and requiredness in other consumers;
they do not request an OWL ``hasKey`` axiom. This projection does not enforce
their uniqueness in OWL. Explicit logical uses such as class ``unique_keys``, inverses,
property characteristics, type designators and rule conditions raise an error.
Global annotation properties support subproperties and simple named-IRI
domain/range axioms; those axioms do not validate annotation values. Anonymous
global annotation-property ranges are unsupported. Reserved structural
predicates such as ``owl:imports`` and ``owl:versionIRI`` cannot be supplied
as annotations; they are distinct from annotation properties.

The example above is also executed by the tests. With ``metaclasses=False``
and either value of ``type_objects`` and ``use_native_uris``, OWLAPI 5.5.0
loads, saves and reloads it with zero OWL 2 DL profile violations and retains
both header annotations. This does not establish DL conformance for every
schema or for the API's default metaclass projection.
Custom ``typeof``-only datatype aliases can also expose an existing singleton
datatype-expression profile defect. The non-header consumer fixture uses an
explicit datatype URI to test type annotations independently of that limitation.

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, as they do for slot
values: ``nodeidentifier`` allows IRIs and blank nodes, and other types produce
literals of their datatype, ``curie`` (``xsd:string``) included. An
``xsd:anyURI`` value is a literal unless ``--xsd-anyuri-as-iri`` is set, which
makes ``uri`` and ``uriorcurie`` values IRIs, as it does for slot values. Use
``nodeidentifier`` for a value that must be a node. 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.
Native ``contributors`` and ``license`` retain their metamodel representation;
native ``license`` remains the authoritative field. Structured agents and
export mappings for native metadata fields require separate support. Scalar
annotations do not preserve nested agent descriptions or automatically
validate all metadata extensions. Generated Python may coerce field values,
and JSON strings alone do not distinguish RDF node kinds.

The real OWLAPI consumer tests run in a dedicated CI job. To run them locally,
prepare the fixture POM's pinned dependency tree with
``maven-dependency-plugin:3.8.1:build-classpath`` and set
``LINKML_OWLAPI_CLASSPATH_FILE`` to its output file. Set
``LINKML_REQUIRE_OWLAPI=1`` to fail when that file is not configured. Ordinary
Python test runs explicitly skip only this external consumer gate when the
classpath is not configured; the dedicated job requires and executes it.

References: `LinkML metamodel refinement
<https://linkml.io/linkml-model/latest/docs/specification/05validation/>`__,
`OWL annotation values <https://www.w3.org/TR/owl2-syntax/#Annotations>`__,
`OWL property typing <https://www.w3.org/TR/owl2-syntax/#Typing_Constraints_of_OWL_2_DL>`__,
`OWL annotation axioms <https://www.w3.org/TR/owl2-syntax/#Annotation_Axioms>`__,
`OWL built-in annotation properties <https://www.w3.org/TR/owl2-syntax/#Annotation_Properties>`__,
`LinkML identifiers <https://linkml.io/linkml-model/latest/docs/identifier/>`__,
`LinkML singular keys <https://linkml.io/linkml-model/latest/docs/key/>`__,
`reserved vocabulary <https://www.w3.org/TR/owl2-syntax/#IRIs>`__,
`existing implements mapping <https://github.com/linkml/linkml/pull/2095>`__,
`RDF Schema resources <https://www.w3.org/TR/rdf-schema/#ch_resource>`__, and
`DCMI license guidance
<https://www.dublincore.org/specifications/dublin-core/dcmi-terms/#http://purl.org/dc/terms/license>`__.

Enums and PermissibleValues
^^^^^^^^^^^^^^^^^^^^^^^^^^^

Expand Down
84 changes: 84 additions & 0 deletions packages/linkml/src/linkml/generators/common/annotations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""RDF annotation terms governed by declared LinkML metamodel extensions."""

from typing import Any

from rdflib import XSD, BNode, Literal, URIRef

from linkml.generators.common.subproperty import is_xsd_anyuri_range
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,
anyuri_as_iri: bool = False,
) -> tuple[URIRef, URIRef | BNode | Literal] | None:
"""Serialize a scalar annotation using an instantiated metaclass's slot.

The value gets the RDF term its declared range gives a slot value.
*anyuri_as_iri* renders ``uri`` and ``uriorcurie`` values as IRIs, as the
generator's ``--xsd-anyuri-as-iri`` renders slot values.

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, anyuri_as_iri))
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, anyuri_as_iri: bool = False
) -> 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
# nodeidentifier denotes a node. An xsd:anyURI value of the uri family is
# a literal unless IRIs are requested, as for slot values; any other
# datatype, including curie's xsd:string, gives a literal.
as_iri = anyuri_as_iri and is_xsd_anyuri_range(schema, slot.range)
if datatype in (SHEX.iri, SHEX.nonLiteral) or as_iri:
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)
Loading
Loading