diff --git a/docs/generators/owl.rst b/docs/generators/owl.rst index 4b6f076fe8..969ebda83c 100644 --- a/docs/generators/owl.rst +++ b/docs/generators/owl.rst @@ -67,6 +67,40 @@ Mapping .. note:: The current default settings for ``metaclasses`` and ``type-objects`` may change in the future +Metadata values that are IRIs +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Schema metadata and ``annotations`` become annotation triples on the ontology, class or +property. Their values are literals, except for properties whose value is a resource by +their own specification: the OWL ontology properties ``owl:versionIRI``, +``owl:priorVersion``, ``owl:backwardCompatibleWith`` and ``owl:incompatibleWith``; +``rdfs:seeAlso`` and ``rdfs:isDefinedBy``; the DCMI terms that relate a resource to +another (``dcterms:license``, ``dcterms:conformsTo``, ``dcterms:references``, +``dcterms:source``, ``dcterms:relation`` and its sub-properties); and +``prov:wasDerivedFrom``, ``prov:wasRevisionOf`` and ``prov:hadPrimarySource``. + +A value of one of these is emitted as an IRI when it is an absolute IRI or a CURIE with a +declared prefix; any other value stays a literal: + +.. code-block:: yaml + + id: https://example.org/onto/v2 + license: https://www.eclipse.org/legal/epl-2.0/ + annotations: + owl:versionInfo: v2 + owl:priorVersion: ex:onto/v1 + prov:wasDerivedFrom: https://example.org/releases/tag/v2.0.0 + +.. code-block:: turtle + + a owl:Ontology ; + owl:versionInfo "v2" ; + owl:priorVersion ; + prov:wasDerivedFrom ; + dcterms:license . + +``dcterms:identifier`` stays a literal: DCMI gives it the range ``rdfs:Literal``. + Enums and PermissibleValues ^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/packages/linkml/src/linkml/generators/owlgen.py b/packages/linkml/src/linkml/generators/owlgen.py index 7ba15df672..ac23f28390 100644 --- a/packages/linkml/src/linkml/generators/owlgen.py +++ b/packages/linkml/src/linkml/generators/owlgen.py @@ -2,6 +2,7 @@ import logging import os +import re from collections import defaultdict from collections.abc import Iterable, Sequence from copy import copy @@ -54,6 +55,49 @@ SWRL = rdflib.Namespace("http://www.w3.org/2003/11/swrl#") SWRLB = rdflib.Namespace("http://www.w3.org/2003/11/swrlb#") +PROV = rdflib.Namespace("http://www.w3.org/ns/prov#") + +IRI_VALUED_METADATA_PROPERTIES: frozenset[URIRef] = frozenset( + { + # OWL 2 ontology properties and the version IRI relate an ontology to other + # ontologies (OWL 2 Structural Specification, sections 3.1 and 3.5). + OWL.versionIRI, + OWL.priorVersion, + OWL.backwardCompatibleWith, + OWL.incompatibleWith, + # rdfs:range rdfs:Resource, defined as pointing at a resource (RDF Schema 1.1, 5.4). + RDFS.seeAlso, + RDFS.isDefinedBy, + # DCMI Metadata Terms relating a resource to another resource. + DCTERMS.license, + DCTERMS.conformsTo, + DCTERMS.relation, + DCTERMS.references, + DCTERMS.isReferencedBy, + DCTERMS.hasVersion, + DCTERMS.isVersionOf, + DCTERMS.replaces, + DCTERMS.isReplacedBy, + DCTERMS.requires, + DCTERMS.isRequiredBy, + DCTERMS.source, + # rdfs:range prov:Entity (PROV-O). + PROV.wasDerivedFrom, + PROV.wasRevisionOf, + PROV.hadPrimarySource, + } +) +"""Metadata properties whose value is a resource by their own specification. + +A value of one of these properties that is an IRI, or a CURIE with a declared prefix, is +emitted as an IRI node; any other value stays a literal. ``dcterms:identifier`` is not +listed: DCMI gives it the range ``rdfs:Literal``. +""" + +# An absolute IRI: an RFC 3986 scheme, a colon, and no character an IRI cannot contain +# (RFC 3987, section 2.2). A CURIE has the same shape, so it is checked against the +# declared prefixes first. +_ABSOLUTE_IRI = re.compile(r"^[A-Za-z][A-Za-z0-9+.\-]*:[^\s<>\"{}|\\^`]+$") @unique @@ -396,6 +440,9 @@ def add_metadata(self, e: Definition | PermissibleValue, uri: URIRef) -> None: obj = URIRef(v) elif metaslot_range == "uriorcurie": obj = URIRef(this_sv.expand_curie(v)) + elif (iri := self._metadata_iri(metaslot_uri, v)) is not None: + # e.g. license and conforms_to: strings in the metamodel, resources by their property + obj = iri elif metaslot_range in self._LANGUAGE_TAGGABLE_RANGES and lang: obj = Literal(v, lang=lang) else: @@ -435,12 +482,31 @@ def add_metadata(self, e: Definition | PermissibleValue, uri: URIRef) -> None: if k_uri == k: k_uri = None if k_uri: - if isinstance(v.value, str): + if (iri := self._metadata_iri(URIRef(k_uri), v.value)) is not None: + obj = iri + elif isinstance(v.value, str): obj = self._literal(v.value, e) else: obj = Literal(v.value) self.graph.add((uri, URIRef(k_uri), obj)) + def _metadata_iri(self, prop: URIRef, value: Any) -> URIRef | None: + """The IRI *value* denotes, if *prop* takes resources and *value* is an IRI or CURIE. + + Only the properties in :data:`IRI_VALUED_METADATA_PROPERTIES` take resources by their + specification. A CURIE is expanded when its prefix is declared; any other value that + is a syntactically valid absolute IRI is used as it is. Returns ``None`` otherwise, and + the value is emitted as a literal, as for every other property. + """ + if prop not in IRI_VALUED_METADATA_PROPERTIES or not isinstance(value, str): + return None + if not _ABSOLUTE_IRI.match(value): + return None + prefix = value.split(":", 1)[0] + if prefix in self.schemaview.namespaces(): + return URIRef(self.schemaview.expand_curie(value)) + return URIRef(value) + def add_class(self, cls: ClassDefinition) -> None: """ Each ClassDefinition is represented as an OWL class. diff --git a/tests/linkml/test_base/__snapshots__/meta.owl b/tests/linkml/test_base/__snapshots__/meta.owl index 4bb91f12de..d0feec5639 100644 --- a/tests/linkml/test_base/__snapshots__/meta.owl +++ b/tests/linkml/test_base/__snapshots__/meta.owl @@ -1502,7 +1502,7 @@ linkml:imported_from a owl:ObjectProperty, linkml:in_language a owl:ObjectProperty, linkml:SlotDefinition ; rdfs:label "in_language" ; - dcterms:conformsTo "https://www.rfc-editor.org/rfc/bcp/bcp47.txt" ; + dcterms:conformsTo ; rdfs:range linkml:String ; skos:definition "the primary language used in the sources" ; skos:inScheme linkml:meta ; @@ -4785,7 +4785,7 @@ linkml:ClassDefinition a owl:Class, linkml:meta a owl:Ontology ; rdfs:label "meta" ; - dcterms:license "https://creativecommons.org/publicdomain/zero/1.0/" ; + dcterms:license ; dcterms:title "LinkML Schema Metamodel" ; skos:definition """The metamodel for schemas defined using the Linked Data Modeling Language framework. diff --git a/tests/linkml/test_biolink_model/__snapshots__/biolink.owl.ttl b/tests/linkml/test_biolink_model/__snapshots__/biolink.owl.ttl index 364bcd01db..061a4dae61 100644 --- a/tests/linkml/test_biolink_model/__snapshots__/biolink.owl.ttl +++ b/tests/linkml/test_biolink_model/__snapshots__/biolink.owl.ttl @@ -79,7 +79,7 @@ os:SomeSomeInterpretation a owl:Class , biolink:LogicalInterpretationEnum ; rdfs:label "some_some" ; rdfs:subClassOf biolink:LogicalInterpretationEnum ; skos:definition "A modifier on a triple that causes the triple to be interpreted as a some-some statement" . - dct:license "https://creativecommons.org/publicdomain/zero/1.0/" ; + dct:license ; pav:version "4.2.5" ; a owl:Ontology ; rdfs:label "Biolink-Model" ; diff --git a/tests/linkml/test_generators/test_owlgen.py b/tests/linkml/test_generators/test_owlgen.py index bb39294f27..77788761f1 100644 --- a/tests/linkml/test_generators/test_owlgen.py +++ b/tests/linkml/test_generators/test_owlgen.py @@ -1232,3 +1232,68 @@ def test_complement_of_union_of_mixed_none_filters_silently(): # Should succeed and return a BNode (the complement expression). assert result is not None assert isinstance(result, BNode) + + +_IRI_METADATA_SCHEMA = """ +id: https://example.org/onto/v2 +name: iri_metadata +license: https://www.eclipse.org/legal/epl-2.0/ +conforms_to: https://example.org/onto/v2/shapes +see_also: + - https://example.org/docs +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/ + owl: http://www.w3.org/2002/07/owl# + prov: http://www.w3.org/ns/prov# + dcterms: http://purl.org/dc/terms/ +default_prefix: ex +imports: + - linkml:types +annotations: + owl:versionInfo: v2 + owl:versionIRI: https://example.org/onto/v2 + owl:priorVersion: ex:onto/v1 + prov:wasDerivedFrom: https://example.org/releases/tag/v2.0.0 + dcterms:references: https://example.org/other + dcterms:identifier: https://example.org/onto + dcterms:creator: The Example Team +classes: + Thing: + annotations: + dcterms:license: Not an IRI, just text +""" + + +def test_iri_valued_metadata_is_emitted_as_iris(): + """Annotations and metadata of properties that take resources are IRIs when their value is one.""" + g = Graph() + g.parse(data=OwlSchemaGenerator(_IRI_METADATA_SCHEMA, metaclasses=False).serialize(), format="turtle") + onto = URIRef("https://example.org/onto/v2") + DCT = Namespace("http://purl.org/dc/terms/") + PROV = Namespace("http://www.w3.org/ns/prov#") + + assert g.value(onto, OWL.versionIRI) == onto + # a CURIE with a declared prefix is expanded + assert g.value(onto, OWL.priorVersion) == URIRef("https://example.org/onto/v1") + assert g.value(onto, PROV.wasDerivedFrom) == URIRef("https://example.org/releases/tag/v2.0.0") + assert g.value(onto, DCT.references) == URIRef("https://example.org/other") + # string-ranged metamodel slots whose property takes resources + assert g.value(onto, DCT.license) == URIRef("https://www.eclipse.org/legal/epl-2.0/") + assert g.value(onto, DCT.conformsTo) == URIRef("https://example.org/onto/v2/shapes") + assert g.value(onto, RDFS.seeAlso) == URIRef("https://example.org/docs") + + +def test_literal_valued_metadata_stays_literal(): + """Other properties, and values that are not IRIs, keep their literals.""" + g = Graph() + g.parse(data=OwlSchemaGenerator(_IRI_METADATA_SCHEMA, metaclasses=False).serialize(), format="turtle") + onto = URIRef("https://example.org/onto/v2") + DCT = Namespace("http://purl.org/dc/terms/") + + assert g.value(onto, OWL.versionInfo) == Literal("v2") + # DCMI gives dcterms:identifier the range rdfs:Literal + assert g.value(onto, DCT.identifier) == Literal("https://example.org/onto") + assert g.value(onto, DCT.creator) == Literal("The Example Team") + # a property that takes resources, with a value that is not an IRI + assert g.value(URIRef("https://example.org/Thing"), DCT.license) == Literal("Not an IRI, just text")