Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/generators/jsonld-context.rst
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,19 @@ declarations and
Any JSON that conforms to the derived JSON Schema (see above) can be
converted to RDF using this context.

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 <https://www.w3.org/TR/json-ld11/#syntax-tokens-and-keywords>`__),
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
^^^^^^^^^^^

Expand Down
13 changes: 13 additions & 0 deletions packages/linkml/src/linkml/generators/jsonldcontextgen.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,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)
Expand Down Expand Up @@ -185,6 +187,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:
Expand All @@ -201,12 +204,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:
Expand Down Expand Up @@ -275,6 +286,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
Expand Down Expand Up @@ -406,6 +418,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:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"@context": {
"@version": 1.1,
"xsd": "http://www.w3.org/2001/XMLSchema#",
"IAO": {
"@id": "http://purl.obolibrary.org/obo/IAO_",
Expand Down
1 change: 1 addition & 0 deletions tests/linkml/test_base/__snapshots__/mappings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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_",
Expand Down
1 change: 1 addition & 0 deletions tests/linkml/test_base/__snapshots__/meta.context.jsonld
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"@context": {
"@version": 1.1,
"xsd": "http://www.w3.org/2001/XMLSchema#",
"IAO": {
"@id": "http://purl.obolibrary.org/obo/IAO_",
Expand Down
1 change: 1 addition & 0 deletions tests/linkml/test_base/__snapshots__/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -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_",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"@context": {
"@version": 1.1,
"xsd": "http://www.w3.org/2001/XMLSchema#",
"AGRKB": "https://www.alliancegenome.org/",
"APO": {
Expand Down
1 change: 1 addition & 0 deletions tests/linkml/test_biolink_model/__snapshots__/biolink.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"@context": {
"@version": 1.1,
"xsd": "http://www.w3.org/2001/XMLSchema#",
"A": "http://example.org/activities/",
"BFO": {
Expand Down
128 changes: 127 additions & 1 deletion tests/linkml/test_generators/test_jsonldcontextgen.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import pytest
from click.testing import CliRunner
from rdflib import Graph, Literal, URIRef
from rdflib import XSD, Graph, Literal, URIRef

from linkml.generators import ContextGenerator, JSONLDGenerator
from linkml.generators.jsonldcontextgen import ContextGenerator as FrameContextGenerator
Expand Down Expand Up @@ -1790,3 +1790,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}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"@context": {
"@version": 1.1,
"xsd": "http://www.w3.org/2001/XMLSchema#",
"GO": {
"@id": "http://purl.obolibrary.org/obo/GO_",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"@context": {
"@version": 1.1,
"xsd": "http://www.w3.org/2001/XMLSchema#",
"bms": "https://microbiomedata/schema/",
"linkml": "https://w3id.org/linkml/",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"@context": {
"@version": 1.1,
"xsd": "http://www.w3.org/2001/XMLSchema#",
"bar": {
"@id": "http://samples.r.us/bar_",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"@context": {
"@version": 1.1,
"xsd": "http://www.w3.org/2001/XMLSchema#",
"bar": {
"@id": "http://samples.r.us/bar_",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"@context": {
"@version": 1.1,
"xsd": "http://www.w3.org/2001/XMLSchema#",
"bar": {
"@id": "http://samples.r.us/bar_",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"@context": {
"@version": 1.1,
"xsd": "http://www.w3.org/2001/XMLSchema#",
"bar": {
"@id": "http://samples.r.us/bar_",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"@context": {
"@version": 1.1,
"xsd": "http://www.w3.org/2001/XMLSchema#",
"bar": {
"@id": "http://samples.r.us/bar_",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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_",
Expand Down
1 change: 1 addition & 0 deletions tests/linkml/test_utils/__snapshots__/uriandcurie.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions tests/linkml/test_utils/__snapshots__/uriandcurie.jsonld
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"@context": {
"@version": 1.1,
"xsd": "http://www.w3.org/2001/XMLSchema#",
"m": {
"@id": "http://example.org/test/uriandcurie",
Expand Down
Loading