From 763f4fd56fb18fbd26f604e83a50c87f7cd047b0 Mon Sep 17 00:00:00 2001 From: jdsika Date: Wed, 21 Oct 2026 22:18:27 +0200 Subject: [PATCH 1/3] feat(rdf): give declared annotation values and native metadata the term of their range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An annotation whose tag a metaclass declares through instantiates gets the RDF term that the slot's range gives a slot value (linkml_runtime.utils.rdf_terms): nodeidentifier, uri and uriorcurie values are IRIs or blank nodes, string values are text with the resolved language, curie values are xsd:string literals, and an enum-ranged slot writes the permissible value's text. gen-owl and gen-shacl share the rule through linkml.generators.common.annotations; without a declaration both keep their earlier behaviour, and metaclasses that declare a tag with different terms are an error. gen-owl applies the same rule to native metadata, so see_also, source and the mappings are IRIs by the metamodel's ranges rather than by a name check, 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: a relative IRI would make the whole graph non-standard RDF and push it onto the canonicalizer's rdflib fallback. OWL 2 §10.1 admits IRIs, anonymous individuals and literals as annotation values. Signed-off-by: jdsika --- docs/examples/owl-dcmi-agent-annotations.yaml | 26 ++ docs/generators/owl.rst | 19 ++ docs/schemas/annotations.md | 32 +++ .../linkml/generators/common/annotations.py | 120 ++++++++ .../linkml/src/linkml/generators/owlgen.py | 42 ++- .../linkml/src/linkml/generators/shaclgen.py | 5 + .../src/linkml_runtime/utils/rdf_terms.py | 17 +- .../test_declared_annotations.py | 259 ++++++++++++++++++ 8 files changed, 509 insertions(+), 11 deletions(-) create mode 100644 docs/examples/owl-dcmi-agent-annotations.yaml create mode 100644 packages/linkml/src/linkml/generators/common/annotations.py create mode 100644 tests/linkml/test_generators/test_declared_annotations.py 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 From 2c9345885b856ab41e3a6121d4d13875a927ae35 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:26:23 -0600 Subject: [PATCH 2/3] build(deps): bump the github-actions group with 5 updates Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/check-external-links.yaml | 4 ++-- .github/workflows/dependency-audit.yaml | 4 ++-- .github/workflows/doc-pages.yaml | 4 ++-- .github/workflows/docker-build.yaml | 2 +- .github/workflows/docs-test.yaml | 4 ++-- .github/workflows/main.yaml | 20 +++++++++---------- .github/workflows/metamodel-compat.yaml | 4 ++-- .github/workflows/pypi-publish.yaml | 8 ++++---- .github/workflows/rustgen.yaml | 4 ++-- .github/workflows/typedb-integration.yaml | 4 ++-- .../workflows/update-feature-dashboard.yaml | 4 ++-- 11 files changed, 31 insertions(+), 31 deletions(-) diff --git a/.github/workflows/check-external-links.yaml b/.github/workflows/check-external-links.yaml index fe2ad217d0..d906b56ac0 100644 --- a/.github/workflows/check-external-links.yaml +++ b/.github/workflows/check-external-links.yaml @@ -23,12 +23,12 @@ jobs: uses: actions/checkout@v7 - name: Set up Python 3. - uses: actions/setup-python@v7.0.0 + uses: actions/setup-python@v7.1.0 with: python-version: "3.12" - name: Install uv - uses: astral-sh/setup-uv@v10.2.0 + uses: astral-sh/setup-uv@v10.3.2 with: version: ${{ env.UV_VERSION }} diff --git a/.github/workflows/dependency-audit.yaml b/.github/workflows/dependency-audit.yaml index 141f2b9e06..b018cc0e45 100644 --- a/.github/workflows/dependency-audit.yaml +++ b/.github/workflows/dependency-audit.yaml @@ -38,13 +38,13 @@ jobs: # Pin uv to a known-good, recent release. - name: Install uv and setup uv caching - uses: astral-sh/setup-uv@v10.2.0 + uses: astral-sh/setup-uv@v10.3.2 with: version: ${{ env.UV_VERSION }} enable-cache: true - name: Set up Python - uses: actions/setup-python@v7.0.0 + uses: actions/setup-python@v7.1.0 id: setup-python with: python-version: 3.13 diff --git a/.github/workflows/doc-pages.yaml b/.github/workflows/doc-pages.yaml index 627b248dc8..ed06ec786b 100644 --- a/.github/workflows/doc-pages.yaml +++ b/.github/workflows/doc-pages.yaml @@ -24,13 +24,13 @@ jobs: git fetch upstream --tags - name: Install uv - uses: astral-sh/setup-uv@v10.2.0 + uses: astral-sh/setup-uv@v10.3.2 with: version: ${{ env.UV_VERSION }} enable-cache: true - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v7.0.0 + uses: actions/setup-python@v7.1.0 id: setup-python with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/docker-build.yaml b/.github/workflows/docker-build.yaml index 231b39a45e..2a723b1374 100644 --- a/.github/workflows/docker-build.yaml +++ b/.github/workflows/docker-build.yaml @@ -55,7 +55,7 @@ jobs: - name: Login to DockerHub if: startsWith(github.ref, 'refs/tags/v') - uses: docker/login-action@v4.6.0 + uses: docker/login-action@v4.7.0 with: username: cjmungall password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} diff --git a/.github/workflows/docs-test.yaml b/.github/workflows/docs-test.yaml index 7f2f0a757b..40602b8242 100644 --- a/.github/workflows/docs-test.yaml +++ b/.github/workflows/docs-test.yaml @@ -36,13 +36,13 @@ jobs: git fetch upstream --tags - name: Install uv - uses: astral-sh/setup-uv@v10.2.0 + uses: astral-sh/setup-uv@v10.3.2 with: version: ${{ env.UV_VERSION }} enable-cache: true - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v7.0.0 + uses: actions/setup-python@v7.1.0 id: setup-python with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index a8c122e1bb..189700bc2d 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -24,11 +24,11 @@ jobs: steps: - uses: actions/checkout@v7 - name: Install uv - uses: astral-sh/setup-uv@v10.2.0 + uses: astral-sh/setup-uv@v10.3.2 with: version: ${{ env.UV_VERSION }} - - uses: actions/setup-python@v7.0.0 + - uses: actions/setup-python@v7.1.0 with: python-version: 3.13 - name: Check pyproject.toml and uv.lock @@ -76,13 +76,13 @@ jobs: git fetch upstream --tags - name: Install uv and setup uv caching - uses: astral-sh/setup-uv@v10.2.0 + uses: astral-sh/setup-uv@v10.3.2 with: version: ${{ env.UV_VERSION }} enable-cache: true - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v7.0.0 + uses: actions/setup-python@v7.1.0 id: setup-python with: python-version: ${{ matrix.python-version }} @@ -139,13 +139,13 @@ jobs: - name: Check out repository uses: actions/checkout@v7 - name: Install uv - uses: astral-sh/setup-uv@v10.2.0 + uses: astral-sh/setup-uv@v10.3.2 with: version: ${{ env.UV_VERSION }} enable-cache: true - name: Set up Python - uses: actions/setup-python@v7.0.0 + uses: actions/setup-python@v7.1.0 id: setup-python with: python-version: ${{ matrix.python-version }} @@ -189,13 +189,13 @@ jobs: - name: Check out repository uses: actions/checkout@v7 - name: Install uv - uses: astral-sh/setup-uv@v10.2.0 + uses: astral-sh/setup-uv@v10.3.2 with: version: ${{ env.UV_VERSION }} enable-cache: true - name: Set up Python - uses: actions/setup-python@v7.0.0 + uses: actions/setup-python@v7.1.0 id: setup-python with: python-version: ${{ matrix.python-version }} @@ -227,12 +227,12 @@ jobs: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v7.0.0 + uses: actions/setup-python@v7.1.0 with: python-version: 3.13 - name: Install uv - uses: astral-sh/setup-uv@v10.2.0 + uses: astral-sh/setup-uv@v10.3.2 with: version: ${{ env.UV_VERSION }} - name: Build source and wheel archives diff --git a/.github/workflows/metamodel-compat.yaml b/.github/workflows/metamodel-compat.yaml index 9984fe83d4..0064d063e6 100644 --- a/.github/workflows/metamodel-compat.yaml +++ b/.github/workflows/metamodel-compat.yaml @@ -28,13 +28,13 @@ jobs: uses: actions/checkout@v7 - name: Install uv - uses: astral-sh/setup-uv@v10.2.0 + uses: astral-sh/setup-uv@v10.3.2 with: version: ${{ env.UV_VERSION }} enable-cache: true - name: Set up Python - uses: actions/setup-python@v7.0.0 + uses: actions/setup-python@v7.1.0 with: python-version: "3.12" diff --git a/.github/workflows/pypi-publish.yaml b/.github/workflows/pypi-publish.yaml index 551c0e0cbd..29f0c8a587 100644 --- a/.github/workflows/pypi-publish.yaml +++ b/.github/workflows/pypi-publish.yaml @@ -18,12 +18,12 @@ jobs: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v7.0.0 + uses: actions/setup-python@v7.1.0 with: python-version: 3.13 - name: Install uv - uses: astral-sh/setup-uv@v10.2.0 + uses: astral-sh/setup-uv@v10.3.2 with: version: ${{ env.UV_VERSION }} @@ -40,7 +40,7 @@ jobs: uv build --all-packages - name: Store built distribution - uses: actions/upload-artifact@v7.0.1 + uses: actions/upload-artifact@v7.0.2 with: name: distribution-files path: dist/ @@ -57,7 +57,7 @@ jobs: id-token: write # This permission is mandatory for trusted publishing. steps: - name: Download built distribution - uses: actions/download-artifact@v8.0.1 + uses: actions/download-artifact@v8.0.2 with: name: distribution-files path: dist diff --git a/.github/workflows/rustgen.yaml b/.github/workflows/rustgen.yaml index 14f62a1234..09a2a6a2a7 100644 --- a/.github/workflows/rustgen.yaml +++ b/.github/workflows/rustgen.yaml @@ -34,13 +34,13 @@ jobs: git fetch upstream --tags - name: Install uv and setup uv caching - uses: astral-sh/setup-uv@v10.2.0 + uses: astral-sh/setup-uv@v10.3.2 with: version: ${{ env.UV_VERSION }} enable-cache: true - name: Set up Python - uses: actions/setup-python@v7.0.0 + uses: actions/setup-python@v7.1.0 with: python-version: '3.12' diff --git a/.github/workflows/typedb-integration.yaml b/.github/workflows/typedb-integration.yaml index c812050fa6..7234a60257 100644 --- a/.github/workflows/typedb-integration.yaml +++ b/.github/workflows/typedb-integration.yaml @@ -31,8 +31,8 @@ jobs: --health-start-period 30s steps: - uses: actions/checkout@v7 - - uses: astral-sh/setup-uv@v10.2.0 - - uses: actions/setup-python@v7.0.0 + - uses: astral-sh/setup-uv@v10.3.2 + - uses: actions/setup-python@v7.1.0 with: python-version: "3.12" - run: uv sync --group typedb --group tests-extra diff --git a/.github/workflows/update-feature-dashboard.yaml b/.github/workflows/update-feature-dashboard.yaml index cfde207543..fab1a06807 100644 --- a/.github/workflows/update-feature-dashboard.yaml +++ b/.github/workflows/update-feature-dashboard.yaml @@ -32,13 +32,13 @@ jobs: git fetch upstream --tags - name: Install uv - uses: astral-sh/setup-uv@v10.2.0 + uses: astral-sh/setup-uv@v10.3.2 with: version: ${{ env.UV_VERSION }} enable-cache: true - name: Set up Python - uses: actions/setup-python@v7.0.0 + uses: actions/setup-python@v7.1.0 with: python-version: "3.12" From 21045aa36c6ea6d30c8a130f7cdbed7e2c8f1621 Mon Sep 17 00:00:00 2001 From: jdsika Date: Thu, 15 Oct 2026 18:16:05 +0200 Subject: [PATCH 3/3] fix(contextgen): declare JSON-LD 1.1 when a generated context needs it A generated context can use JSON-LD 1.1 constructs: scoped contexts on enum slots and on class-level range overrides, and @prefix. A JSON-LD 1.0 processor ignores them silently, so datatype coercions are lost. A context that uses one now starts with "@version": 1.1. It makes a JSON-LD 1.0 processor stop (JSON-LD 1.1 section 1.7), and a JSON-LD 1.1 processor then requires 1.1 processing (JSON-LD 1.1 API section 4.1.2). The generator marks the three places where it emits such a construct, so no keyword table is needed, and the declaration follows what the context actually contains: with --no-model the scoped contexts are left out, with --no-prefixes the @prefix entries. A context without a 1.1 construct stays loadable by a JSON-LD 1.0 processor. gen-jsonld embeds the declaration with the generated context. Signed-off-by: jdsika --- docs/generators/jsonld-context.rst | 13 ++ .../src/linkml/generators/jsonldcontextgen.py | 13 ++ .../__snapshots__/mappings.context.jsonld | 1 + .../test_base/__snapshots__/mappings.json | 1 + .../__snapshots__/meta.context.jsonld | 1 + .../linkml/test_base/__snapshots__/meta.json | 1 + .../__snapshots__/biolink.context.jsonld | 1 + .../__snapshots__/biolink.json | 1 + .../__snapshots__/kitchen_sink.jsonld | 1 + .../test_generators/test_jsonldcontextgen.py | 126 ++++++++++++++++++ .../__snapshots__/issue_344_context.json | 1 + .../__snapshots__/issue_368.context.jsonld | 1 + .../gencontext/meta.context.jsonld | 1 + .../gencontext/meta_context.jsonld | 1 + .../simple_uri_test.merge.context.jsonld | 1 + ...ri_test.merge.prefixes_only.context.jsonld | 1 + .../simple_uri_test.no_merge.context.jsonld | 1 + ...test.no_merge.prefixes_only.context.jsonld | 1 + .../genjsonld/simple_uri_test.context.jsonld | 1 + .../genjsonld/simple_uri_test.jsonld | 1 + .../test_utils/__snapshots__/uriandcurie.json | 1 + .../__snapshots__/uriandcurie.jsonld | 1 + 22 files changed, 171 insertions(+) diff --git a/docs/generators/jsonld-context.rst b/docs/generators/jsonld-context.rst index 18cec34644..1daf6caef2 100644 --- a/docs/generators/jsonld-context.rst +++ b/docs/generators/jsonld-context.rst @@ -53,6 +53,19 @@ derived from ``string`` with ``uri: xsd:anyURI`` keeps URI references as ``xsd:anyURI`` literals (see :ref:`links-and-uri-references`). +JSON-LD version +^^^^^^^^^^^^^^^ + +A generated context that uses a JSON-LD 1.1 construct starts with +``"@version": 1.1``. The generator emits three: scoped contexts for enum slots +and for class-level range overrides, and ``@prefix`` (see below). A JSON-LD 1.0 +processor would silently ignore them; the number ``1.1`` makes it stop instead +(`JSON-LD 1.1 §1.7 `__), +and selects JSON-LD 1.1 processing in processors that support both. A context +without such a construct, for example one generated with ``--no-model`` or for a +schema without enums and overrides, has no version line and stays loadable by a +JSON-LD 1.0 processor. + CURIE usage ^^^^^^^^^^^ diff --git a/packages/linkml/src/linkml/generators/jsonldcontextgen.py b/packages/linkml/src/linkml/generators/jsonldcontextgen.py index 4916fa07c3..c1d6720f21 100644 --- a/packages/linkml/src/linkml/generators/jsonldcontextgen.py +++ b/packages/linkml/src/linkml/generators/jsonldcontextgen.py @@ -126,6 +126,8 @@ def _collect_external_elements(sv: SchemaView) -> tuple[set[str], set[str]]: return external_classes, external_slots def visit_schema(self, base: str | Namespace | None = None, output: str | None = None, **_): + # Whether a class or slot definition carries a scoped context, a JSON-LD 1.1 construct + self._uses_scoped_contexts = False # Add any explicitly declared prefixes for prefix in self.schema.prefixes.values(): self.emit_prefixes.add(prefix.prefix_prefix) @@ -178,6 +180,7 @@ def end_schema( comments.source = self.schema.source_file context.comments = comments context_content = {"xsd": "http://www.w3.org/2001/XMLSchema#"} + uses_prefix_flag = False if base: base = str(base) if "://" not in base: @@ -194,12 +197,20 @@ def end_schema( prefix_obj = JsonObj() prefix_obj["@id"] = url prefix_obj["@prefix"] = True + uses_prefix_flag = True context_content[prefix] = prefix_obj if model: for k, v in self.context_body.items(): context_content[k] = v for k, v in self.slot_class_maps.items(): context_content[k] = v + if uses_prefix_flag or (model and self._uses_scoped_contexts): + # The context uses a JSON-LD 1.1 construct (``@prefix``, a scoped context), which a + # JSON-LD 1.0 processor silently ignores. ``"@version": 1.1`` makes such a processor + # stop instead (JSON-LD 1.1 §1.7), and a JSON-LD 1.1 processor then requires 1.1 + # processing (JSON-LD 1.1 API §4.1.2, step 5.5). A context without such a + # construct stays loadable by a 1.0 processor. + context_content = {"@version": 1.1, **context_content} context["@context"] = context_content if output and not self.embed_context_in_frame: with open(output, "w", encoding="UTF-8") as outf: @@ -268,6 +279,7 @@ def visit_class(self, cls: ClassDefinition) -> bool: scoped_context = self._build_scoped_context(cls) if scoped_context: class_def["@context"] = scoped_context + self._uses_scoped_contexts = True if class_def: self.slot_class_maps[cn] = class_def @@ -389,6 +401,7 @@ def visit_slot(self, aliased_slot_name: str, slot: SlotDefinition) -> None: slot_def["@context"] = {**ENUM_CONTEXT, "@vocab": vocab_ns} else: slot_def["@context"] = ENUM_CONTEXT + self._uses_scoped_contexts = True # Add the necessary prefixes to the namespace skos = self.namespaces.prefix_for(SKOS) if not skos: diff --git a/tests/linkml/test_base/__snapshots__/mappings.context.jsonld b/tests/linkml/test_base/__snapshots__/mappings.context.jsonld index e14c87bb34..ffb0578dc4 100644 --- a/tests/linkml/test_base/__snapshots__/mappings.context.jsonld +++ b/tests/linkml/test_base/__snapshots__/mappings.context.jsonld @@ -1,5 +1,6 @@ { "@context": { + "@version": 1.1, "xsd": "http://www.w3.org/2001/XMLSchema#", "IAO": { "@id": "http://purl.obolibrary.org/obo/IAO_", diff --git a/tests/linkml/test_base/__snapshots__/mappings.json b/tests/linkml/test_base/__snapshots__/mappings.json index 02354810c1..4f1decb6dc 100644 --- a/tests/linkml/test_base/__snapshots__/mappings.json +++ b/tests/linkml/test_base/__snapshots__/mappings.json @@ -474,6 +474,7 @@ "@context": [ "https://w3id.org/linkml/meta.context.jsonld", { + "@version": 1.1, "xsd": "http://www.w3.org/2001/XMLSchema#", "IAO": { "@id": "http://purl.obolibrary.org/obo/IAO_", diff --git a/tests/linkml/test_base/__snapshots__/meta.context.jsonld b/tests/linkml/test_base/__snapshots__/meta.context.jsonld index d51c9c8bf3..f31068d31e 100644 --- a/tests/linkml/test_base/__snapshots__/meta.context.jsonld +++ b/tests/linkml/test_base/__snapshots__/meta.context.jsonld @@ -1,5 +1,6 @@ { "@context": { + "@version": 1.1, "xsd": "http://www.w3.org/2001/XMLSchema#", "IAO": { "@id": "http://purl.obolibrary.org/obo/IAO_", diff --git a/tests/linkml/test_base/__snapshots__/meta.json b/tests/linkml/test_base/__snapshots__/meta.json index 98b6fe7d0d..be3ec4b40e 100644 --- a/tests/linkml/test_base/__snapshots__/meta.json +++ b/tests/linkml/test_base/__snapshots__/meta.json @@ -8910,6 +8910,7 @@ "@context": [ "https://w3id.org/linkml/meta.context.jsonld", { + "@version": 1.1, "xsd": "http://www.w3.org/2001/XMLSchema#", "IAO": { "@id": "http://purl.obolibrary.org/obo/IAO_", diff --git a/tests/linkml/test_biolink_model/__snapshots__/biolink.context.jsonld b/tests/linkml/test_biolink_model/__snapshots__/biolink.context.jsonld index 16621443df..bae530473e 100644 --- a/tests/linkml/test_biolink_model/__snapshots__/biolink.context.jsonld +++ b/tests/linkml/test_biolink_model/__snapshots__/biolink.context.jsonld @@ -1,5 +1,6 @@ { "@context": { + "@version": 1.1, "xsd": "http://www.w3.org/2001/XMLSchema#", "AGRKB": "https://www.alliancegenome.org/", "APO": { diff --git a/tests/linkml/test_biolink_model/__snapshots__/biolink.json b/tests/linkml/test_biolink_model/__snapshots__/biolink.json index 02bc375a8f..49a702e56a 100644 --- a/tests/linkml/test_biolink_model/__snapshots__/biolink.json +++ b/tests/linkml/test_biolink_model/__snapshots__/biolink.json @@ -35995,6 +35995,7 @@ "@context": [ "https://w3id.org/linkml/meta.context.jsonld", { + "@version": 1.1, "xsd": "http://www.w3.org/2001/XMLSchema#", "AGRKB": "https://www.alliancegenome.org/", "APO": { diff --git a/tests/linkml/test_generators/__snapshots__/kitchen_sink.jsonld b/tests/linkml/test_generators/__snapshots__/kitchen_sink.jsonld index 299abdf340..836133a929 100644 --- a/tests/linkml/test_generators/__snapshots__/kitchen_sink.jsonld +++ b/tests/linkml/test_generators/__snapshots__/kitchen_sink.jsonld @@ -1,5 +1,6 @@ { "@context": { + "@version": 1.1, "xsd": "http://www.w3.org/2001/XMLSchema#", "A": "http://example.org/activities/", "BFO": { diff --git a/tests/linkml/test_generators/test_jsonldcontextgen.py b/tests/linkml/test_generators/test_jsonldcontextgen.py index 6c56ab3fc5..05c61ccda5 100644 --- a/tests/linkml/test_generators/test_jsonldcontextgen.py +++ b/tests/linkml/test_generators/test_jsonldcontextgen.py @@ -1548,3 +1548,129 @@ def test_identifier_context_round_trips_to_rdf(tmp_path, use_curies): ex = "https://example.org/identifier-test/" assert set(graph) == {(URIRef(ex + "thing1"), URIRef(ex + "name"), Literal("n"))} + + +VERSION_SCHEMA = """ +id: https://example.org/version +name: version +prefixes: + ex: https://example.org/version/ + linkml: https://w3id.org/linkml/ +default_prefix: ex +default_range: string +imports: [linkml:types] +slots: + value: + slot_uri: ex:value +classes: + Quantity: + class_uri: ex:Quantity + slots: [value] + slot_usage: + value: + range: float + Token: + class_uri: ex:Token + slots: [value] + Thing: + class_uri: ex:Thing + tree_root: true + attributes: + reading: {range: Quantity, inlined: true} + label: {range: Token, inlined: true} +""" + + +PLAIN_SCHEMA = """ +id: https://example.org/plain +name: plain +prefixes: + ex: https://example.org/plain/ + linkml: https://w3id.org/linkml/ +default_prefix: ex +default_range: string +imports: [linkml:types] +classes: + Thing: + attributes: + label: {} +""" + +# A namespace that ends in neither "/" nor "#" is declared with "@prefix": true. +PREFIX_SCHEMA = PLAIN_SCHEMA.replace("https://example.org/plain/", "https://example.org/plain") + + +def test_context_with_a_json_ld_1_1_construct_declares_it(tmp_path): + """A context with a scoped context declares ``"@version": 1.1`` first. + + JSON-LD 1.1 §1.7: the value is the number 1.1, which makes a JSON-LD 1.0 + processor stop instead of ignoring the 1.1 constructs. + """ + schema = tmp_path / "version.yaml" + schema.write_text(VERSION_SCHEMA, encoding="utf-8") + context = json.loads(ContextGenerator(str(schema)).serialize())["@context"] + assert next(iter(context)) == "@version" + assert context["@version"] == 1.1 and isinstance(context["@version"], float) + + +@pytest.mark.parametrize( + "schema_text,options,declared", + [ + pytest.param(VERSION_SCHEMA, {}, True, id="scoped-context"), + pytest.param(VERSION_SCHEMA, {"model": False}, False, id="scoped-context-left-out"), + pytest.param(PLAIN_SCHEMA, {}, False, id="plain"), + pytest.param(PREFIX_SCHEMA, {}, True, id="prefix"), + pytest.param(PREFIX_SCHEMA, {"prefixes": False}, False, id="prefix-left-out"), + pytest.param( + PLAIN_SCHEMA + " Tagged:\n attributes:\n color: {range: Color}\nenums:\n Color:\n" + " permissible_values: {red: {}}\n", + {}, + True, + id="enum", + ), + ], +) +def test_version_is_declared_exactly_when_a_json_ld_1_1_construct_is_used(tmp_path, schema_text, options, declared): + """``@prefix`` and scoped contexts are JSON-LD 1.1 constructs; a context without them stays + loadable by a JSON-LD 1.0 processor, so it doesn't declare ``"@version": 1.1``.""" + schema = tmp_path / "schema.yaml" + schema.write_text(schema_text, encoding="utf-8") + context = json.loads(ContextGenerator(str(schema)).serialize(**options))["@context"] + assert ("@version" in context) is declared + if declared: + assert next(iter(context)) == "@version" + + +@pytest.mark.parametrize( + "schema_text,declared", + [pytest.param(VERSION_SCHEMA, False, id="scoped-context"), pytest.param(PREFIX_SCHEMA, True, id="prefix")], +) +def test_embedded_context_follows_the_same_rule(tmp_path, schema_text, declared): + """gen-jsonld embeds a prefix-only generated context, which declares the version only if it needs it.""" + schema = tmp_path / "embedded.yaml" + schema.write_text(schema_text, encoding="utf-8") + embedded = json.loads(JSONLDGenerator(str(schema)).serialize())["@context"] + generated = next(c for c in embedded if isinstance(c, dict)) + assert ("@version" in generated) is declared + + +@pytest.mark.parametrize("version", [None, 1.0], ids=["processor-default", "json-ld-1.0-requested"]) +def test_type_scoped_coercion_survives_json_ld_1_0_processing(tmp_path, version): + """A class-level range override reaches the data even when the caller asks rdflib for JSON-LD 1.0. + + The override is a type-scoped context, a JSON-LD 1.1 construct. rdflib in + JSON-LD 1.0 mode ignores it, so ``1.5`` would become ``xsd:double``. Asked + for 1.0, rdflib switches to 1.1 when the context declares ``@version``; a + conformant JSON-LD 1.1 processor stops with a processing mode conflict + instead (JSON-LD 1.1 API §4.1.2). Either way the coercion is not silently lost. + """ + schema = tmp_path / "version.yaml" + schema.write_text(VERSION_SCHEMA, encoding="utf-8") + context = json.loads(ContextGenerator(str(schema)).serialize()) + doc = {**context, "@type": "Thing", "reading": {"@type": "Quantity", "value": 1.5}, "label": {"value": "x"}} + + options = {} if version is None else {"version": version} + graph = Graph().parse(data=json.dumps(doc), format="json-ld", **options) + + values = {o.datatype for o in graph.objects(None, URIRef("https://example.org/version/value"))} + assert values == {XSD.float, None} diff --git a/tests/linkml/test_issues/__snapshots__/issue_344_context.json b/tests/linkml/test_issues/__snapshots__/issue_344_context.json index 67f62a0b81..e5d0f534be 100644 --- a/tests/linkml/test_issues/__snapshots__/issue_344_context.json +++ b/tests/linkml/test_issues/__snapshots__/issue_344_context.json @@ -1,5 +1,6 @@ { "@context": { + "@version": 1.1, "xsd": "http://www.w3.org/2001/XMLSchema#", "GO": { "@id": "http://purl.obolibrary.org/obo/GO_", diff --git a/tests/linkml/test_issues/__snapshots__/issue_368.context.jsonld b/tests/linkml/test_issues/__snapshots__/issue_368.context.jsonld index 64d29f16df..ab40384ceb 100644 --- a/tests/linkml/test_issues/__snapshots__/issue_368.context.jsonld +++ b/tests/linkml/test_issues/__snapshots__/issue_368.context.jsonld @@ -1,5 +1,6 @@ { "@context": { + "@version": 1.1, "xsd": "http://www.w3.org/2001/XMLSchema#", "bms": "https://microbiomedata/schema/", "linkml": "https://w3id.org/linkml/", diff --git a/tests/linkml/test_scripts/__snapshots__/gencontext/meta.context.jsonld b/tests/linkml/test_scripts/__snapshots__/gencontext/meta.context.jsonld index ce935718c5..c08c2b5c55 100644 --- a/tests/linkml/test_scripts/__snapshots__/gencontext/meta.context.jsonld +++ b/tests/linkml/test_scripts/__snapshots__/gencontext/meta.context.jsonld @@ -5,6 +5,7 @@ "source": "kitchen_sink.yaml" }, "@context": { + "@version": 1.1, "xsd": "http://www.w3.org/2001/XMLSchema#", "A": "http://example.org/activities/", "BFO": { diff --git a/tests/linkml/test_scripts/__snapshots__/gencontext/meta_context.jsonld b/tests/linkml/test_scripts/__snapshots__/gencontext/meta_context.jsonld index ba1f5c389a..d893742c6b 100644 --- a/tests/linkml/test_scripts/__snapshots__/gencontext/meta_context.jsonld +++ b/tests/linkml/test_scripts/__snapshots__/gencontext/meta_context.jsonld @@ -5,6 +5,7 @@ "source": "kitchen_sink.yaml" }, "@context": { + "@version": 1.1, "xsd": "http://www.w3.org/2001/XMLSchema#", "A": "http://example.org/activities/", "BFO": { diff --git a/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.merge.context.jsonld b/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.merge.context.jsonld index 3092b3dd07..2d36420bd9 100644 --- a/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.merge.context.jsonld +++ b/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.merge.context.jsonld @@ -1,5 +1,6 @@ { "@context": { + "@version": 1.1, "xsd": "http://www.w3.org/2001/XMLSchema#", "bar": { "@id": "http://samples.r.us/bar_", diff --git a/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.merge.prefixes_only.context.jsonld b/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.merge.prefixes_only.context.jsonld index e982cc2fda..cfb713c120 100644 --- a/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.merge.prefixes_only.context.jsonld +++ b/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.merge.prefixes_only.context.jsonld @@ -1,5 +1,6 @@ { "@context": { + "@version": 1.1, "xsd": "http://www.w3.org/2001/XMLSchema#", "bar": { "@id": "http://samples.r.us/bar_", diff --git a/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.no_merge.context.jsonld b/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.no_merge.context.jsonld index 3092b3dd07..2d36420bd9 100644 --- a/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.no_merge.context.jsonld +++ b/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.no_merge.context.jsonld @@ -1,5 +1,6 @@ { "@context": { + "@version": 1.1, "xsd": "http://www.w3.org/2001/XMLSchema#", "bar": { "@id": "http://samples.r.us/bar_", diff --git a/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.no_merge.prefixes_only.context.jsonld b/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.no_merge.prefixes_only.context.jsonld index e982cc2fda..cfb713c120 100644 --- a/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.no_merge.prefixes_only.context.jsonld +++ b/tests/linkml/test_scripts/__snapshots__/gencontext/simple_uri_test.no_merge.prefixes_only.context.jsonld @@ -1,5 +1,6 @@ { "@context": { + "@version": 1.1, "xsd": "http://www.w3.org/2001/XMLSchema#", "bar": { "@id": "http://samples.r.us/bar_", diff --git a/tests/linkml/test_scripts/__snapshots__/genjsonld/simple_uri_test.context.jsonld b/tests/linkml/test_scripts/__snapshots__/genjsonld/simple_uri_test.context.jsonld index ad8978b81f..b94cdbfb73 100644 --- a/tests/linkml/test_scripts/__snapshots__/genjsonld/simple_uri_test.context.jsonld +++ b/tests/linkml/test_scripts/__snapshots__/genjsonld/simple_uri_test.context.jsonld @@ -1,5 +1,6 @@ { "@context": { + "@version": 1.1, "xsd": "http://www.w3.org/2001/XMLSchema#", "bar": { "@id": "http://samples.r.us/bar_", diff --git a/tests/linkml/test_scripts/__snapshots__/genjsonld/simple_uri_test.jsonld b/tests/linkml/test_scripts/__snapshots__/genjsonld/simple_uri_test.jsonld index 97c502567c..12eafd5d03 100644 --- a/tests/linkml/test_scripts/__snapshots__/genjsonld/simple_uri_test.jsonld +++ b/tests/linkml/test_scripts/__snapshots__/genjsonld/simple_uri_test.jsonld @@ -492,6 +492,7 @@ "@context": [ "https://w3id.org/linkml/meta.context.jsonld", { + "@version": 1.1, "xsd": "http://www.w3.org/2001/XMLSchema#", "bar": { "@id": "http://samples.r.us/bar_", diff --git a/tests/linkml/test_utils/__snapshots__/uriandcurie.json b/tests/linkml/test_utils/__snapshots__/uriandcurie.json index 97198624f6..32ea0a7413 100644 --- a/tests/linkml/test_utils/__snapshots__/uriandcurie.json +++ b/tests/linkml/test_utils/__snapshots__/uriandcurie.json @@ -181,6 +181,7 @@ "@context": [ "https://w3id.org/linkml/meta.context.jsonld", { + "@version": 1.1, "xsd": "http://www.w3.org/2001/XMLSchema#", "m": { "@id": "http://example.org/test/uriandcurie", diff --git a/tests/linkml/test_utils/__snapshots__/uriandcurie.jsonld b/tests/linkml/test_utils/__snapshots__/uriandcurie.jsonld index f87f92da56..5aa031de7f 100644 --- a/tests/linkml/test_utils/__snapshots__/uriandcurie.jsonld +++ b/tests/linkml/test_utils/__snapshots__/uriandcurie.jsonld @@ -1,5 +1,6 @@ { "@context": { + "@version": 1.1, "xsd": "http://www.w3.org/2001/XMLSchema#", "m": { "@id": "http://example.org/test/uriandcurie",