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
24 changes: 24 additions & 0 deletions docs/examples/owl-dcmi-agent-annotations.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
id: https://example.org/model
name: model
prefixes:
ex: https://example.org/
linkml: https://w3id.org/linkml/
dcterms: http://purl.org/dc/terms/
owl: http://www.w3.org/2002/07/owl#
imports: [linkml:types]
default_prefix: ex
instantiates: [ex:AgentMetadata]
annotations:
creator_label: The Example Team
publisher: ex:team
classes:
AgentMetadata:
attributes:
creator_label:
slot_uri: dcterms:creator
range: string
implements: [owl:AnnotationProperty]
publisher:
slot_uri: dcterms:publisher
range: nodeidentifier
implements: [owl:AnnotationProperty]
61 changes: 60 additions & 1 deletion docs/generators/owl.rst
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,66 @@ References: `LinkML metamodel refinement
`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>`__.
<https://www.dublincore.org/specifications/dublin-core/dcmi-terms/#license>`__.

DCMI agent metadata
^^^^^^^^^^^^^^^^^^^

To name an agent by IRI, declare a ``nodeidentifier`` annotation. To record a
name or other textual identifier, declare ``string``. Independently, use
``implements: [owl:AnnotationProperty]`` for the OWL annotation role.
``instantiates`` identifies the metaclass supplying those declarations; it
does not select that role automatically. These choices work for
``dcterms:creator``, ``dcterms:contributor``, ``dcterms:publisher`` and
``dcterms:rightsHolder`` through the same mechanism as the custom predicates above:

.. literalinclude:: ../examples/owl-dcmi-agent-annotations.yaml
:language: yaml

The ontology header contains ``dcterms:creator "The Example Team"`` and
``dcterms:publisher <https://example.org/team>``, with both predicates declared
as annotation properties. This projection omits OWL logical restrictions on
the metadata slots; their LinkML constraints remain available to instance
validators. OWL annotation axioms do not validate those values.
A string declaration stays literal even when its value looks like a URL.
For SHACL, put the same
``instantiates`` and ``annotations`` on a class or slot to annotate its shape,
with ``--include-annotations`` enabled.

Tests execute this exact example with ``metaclasses=False`` and both values
of ``use_native_uris`` and ``type_objects``. OWLAPI 5.5.0 loads, saves and
reloads each generated artifact with zero OWL 2 DL profile violations and
retains the literal creator and IRI publisher. This is the same controlled
consumer contract as the generic example, not a claim about every schema or
the API's default metaclass projection.

The `DCMI definitions
<https://www.dublincore.org/specifications/dublin-core/dcmi-terms/>`__ use
``dcam:rangeIncludes`` with ``dcterms:Agent`` for these properties. The
`rangeIncludes definition
<https://www.dublincore.org/specifications/dublin-core/dcmi-terms/#rangeIncludes>`__
suggests a class for values; it is not an ``rdfs:range`` restriction or an
instruction to coerce strings into IRIs. Creator and rights-holder guidance
recommends URIs while permitting literal identifiers. The declared LinkML
range determines the emitted RDF term.

Use the native ``contributors`` field for ordinary contributor identifiers;
it already carries URI-or-CURIE values and maps to ``dcterms:contributor``.
A separately declared annotation with that RDF predicate needs its own
declaration. Native `created_by <https://linkml.io/linkml-model/latest/docs/created_by/>`__
maps to ``pav:createdBy`` and does not emit ``dcterms:creator``. Although the
metamodel declares a standalone `publisher <https://linkml.io/linkml-model/latest/docs/publisher/>`__
slot, the current ``SchemaDefinition`` runtime does not accept
a top-level ``publisher`` field. The example therefore declares publisher
metadata through ``instantiates`` and annotations.

Keep native ``contributors`` and ``license`` authoritative; native ``license``
retains its existing metamodel representation. Scalar agent
annotations do not preserve structured person or organization descriptions,
and native-field RDF export mappings require separate support. Declaring the
value representation and OWL role avoids another generator flag or a
vocabulary-specific list.


Enums and PermissibleValues
^^^^^^^^^^^^^^^^^^^^^^^^^^^
Expand Down
96 changes: 95 additions & 1 deletion tests/linkml/test_generators/test_declared_annotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import pytest
import yaml
from pyshacl import validate
from rdflib import RDF, SH, XSD, BNode, Graph, Literal, URIRef
from rdflib import OWL, RDF, SH, XSD, BNode, Graph, Literal, URIRef

from linkml.generators.owlgen import OwlSchemaGenerator
from linkml.generators.shaclgen import ShaclGenerator
Expand Down Expand Up @@ -236,3 +236,97 @@ def test_annotations_do_not_constrain_data() -> None:
data = Graph()
data.add((URIRef(EX + "instance"), RDF.type, URIRef(EX + "Thing")))
assert validate(data, shacl_graph=graph, meta_shacl=True)[0]


@pytest.mark.parametrize("generator", ["owl", "shacl"])
@pytest.mark.parametrize("property_name", ["creator", "contributor", "publisher", "rightsHolder"])
@pytest.mark.parametrize(
"range_name,value",
[
("nodeidentifier", "ex:team"),
("nodeidentifier", "https://example.org/team"),
("string", "The Example Team"),
("string", "https://example.org/team"),
],
)
def test_dcmi_agent_annotation_declarations(generator: str, property_name: str, range_name: str, value: str) -> None:
"""DCMI agent values follow declarations, including deliberately literal URL text."""
schema = yaml.safe_load(SCHEMA)
schema["prefixes"]["dcterms"] = "http://purl.org/dc/terms/"
schema["classes"]["Metadata"]["attributes"] = {
"agent": {
"slot_uri": "dcterms:" + property_name,
"range": range_name,
"implements": ["owl:AnnotationProperty"],
}
}
schema["classes"]["Thing"].pop("annotations")
metadata = {"instantiates": ["ex:Profile"], "annotations": {"dcterms:" + property_name: value}}
# OWL annotates the ontology header; SHACL annotates the class shape.
if generator == "owl":
schema.update(metadata)
subject = URIRef(EX + "model")
else:
schema["classes"]["Thing"].update(metadata)
subject = URIRef(EX + "Thing")
graph = _graph(yaml.safe_dump(schema), generator, default_language="en")
predicate = URIRef("http://purl.org/dc/terms/" + property_name)
expected = URIRef(EX + "team") if range_name == "nodeidentifier" else Literal(value, lang="en")
assert set(graph.objects(subject, predicate)) == {expected}
if generator == "owl":
assert (predicate, RDF.type, OWL.AnnotationProperty) in graph
assert (predicate, RDF.type, OWL.ObjectProperty) not in graph
assert (predicate, RDF.type, OWL.DatatypeProperty) not in graph
for property_uri in [predicate, URIRef(EX + "agent")]:
assert not list(graph.subjects(OWL.onProperty, property_uri))


def test_declared_contributor_matches_metamodel_field() -> None:
"""The standard contributors field and an explicitly declared node annotation agree."""
schema = yaml.safe_load(SCHEMA)
schema["prefixes"]["dcterms"] = "http://purl.org/dc/terms/"
schema["classes"]["Metadata"]["attributes"]["contributor"] = {
"slot_uri": "dcterms:contributor",
"range": "nodeidentifier",
"implements": ["owl:AnnotationProperty"],
}
schema.update(
instantiates=["ex:Profile"],
contributors=["ex:team"],
annotations={"contributor": "ex:team"},
)
predicate = URIRef("http://purl.org/dc/terms/contributor")
for fields in [("contributors", "annotations"), ("contributors",), ("annotations",)]:
source = {
key: value for key, value in schema.items() if key not in {"contributors", "annotations"} or key in fields
}
graph = _graph(yaml.safe_dump(source), "owl")
assert set(graph.objects(URIRef(EX + "model"), predicate)) == {URIRef(EX + "team")}
assert (predicate, RDF.type, OWL.AnnotationProperty) in graph
assert not list(graph.subjects(OWL.onProperty, predicate))


@pytest.mark.parametrize("property_name", ["creator", "contributor", "publisher", "rightsHolder"])
def test_undeclared_dcmi_agent_url_stays_literal(property_name: str) -> None:
"""A familiar DCMI predicate alone does not authorize OWL node coercion."""
schema = yaml.safe_load(SCHEMA)
schema["prefixes"]["dcterms"] = "http://purl.org/dc/terms/"
schema["annotations"] = {"dcterms:" + property_name: EX + "team"}
graph = _graph(yaml.safe_dump(schema), "owl")
assert set(graph.objects(URIRef(EX + "model"), URIRef("http://purl.org/dc/terms/" + property_name))) == {
Literal(EX + "team")
}
predicate = URIRef("http://purl.org/dc/terms/" + property_name)
assert (predicate, RDF.type, OWL.AnnotationProperty) in graph
assert not list(graph.subjects(OWL.onProperty, predicate))


def test_native_created_by_has_its_own_mapping() -> None:
"""Native creator metadata emits its PAV predicate independently of custom DCMI annotations."""
schema = yaml.safe_load(SCHEMA)
schema["created_by"] = "ex:team"
graph = _graph(yaml.safe_dump(schema), "owl")
predicate = URIRef("http://purl.org/pav/createdBy")
assert set(graph.objects(URIRef(EX + "model"), predicate)) == {URIRef(EX + "team")}
assert (predicate, RDF.type, OWL.AnnotationProperty) in graph
assert not list(graph.objects(URIRef(EX + "model"), URIRef("http://purl.org/dc/terms/creator")))
21 changes: 21 additions & 0 deletions tests/linkml/test_generators/test_owl_annotation_properties.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
EX = "https://example.org/"
ROOT = Path(__file__).resolve().parents[3]
EXAMPLE = ROOT / "docs/examples/owl-annotation-properties.yaml"
DCMI_EXAMPLE = ROOT / "docs/examples/owl-dcmi-agent-annotations.yaml"
HARNESS = Path(__file__).parent / "input/owl_annotation_properties"


Expand Down Expand Up @@ -877,6 +878,26 @@ def test_owlapi_documentation_roundtrip(
_probe(schema, tmp_path, owlapi_classpath, rows, use_native_uris=native, type_objects=type_objects)


@pytest.mark.integration
@pytest.mark.parametrize("native", [False, True])
@pytest.mark.parametrize("type_objects", [False, True])
def test_owlapi_dcmi_documentation_roundtrip(
tmp_path: Path, owlapi_classpath: str, native: bool, type_objects: bool
) -> None:
"""The exact DCMI example retains literal/node annotations with zero profile violations in every tested mode."""
schema = yaml.safe_load(DCMI_EXAMPLE.read_text())
rows = [
["ontology", str(DCTERMS.creator), "LITERAL", "The Example Team", "", ""],
["ontology", str(DCTERMS.publisher), "IRI", EX + "team", "", ""],
]
_probe(
schema, tmp_path, owlapi_classpath, rows, use_native_uris=native, type_objects=type_objects, metaclasses=False
)
graph = Graph().parse(tmp_path / "generated.ttl", format="turtle")
for predicate in [DCTERMS.creator, DCTERMS.publisher]:
assert (predicate, RDF.type, OWL.AnnotationProperty) in graph


@pytest.mark.integration
def test_owlapi_non_header_owners(schema: dict, tmp_path: Path, owlapi_classpath: str) -> None:
"""Class, slot, type, enum and PV annotation assertions survive a real OWLAPI round-trip."""
Expand Down
Loading