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
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@
which replaces the role of the static `MAX_CONTEXT_URLS`. The constructor
now accepts a `max_context_urls` parameter that sets the value of
`max_context_urls` which defaults to `MAX_CONTEXT_URLS`.

- `pyld.fromRdf()` now supports compound literals when serializing RDF to JSON-LD.
It therefore also accepts the value `'compound-literal'` for the `'rdfDirection'` option.
Fixes [fromRdf#tdi11](https://w3c.github.io/json-ld-api/tests/fromRdf-manifest.html#tdi11)
and [fromRdf#tdi12](https://w3c.github.io/json-ld-api/tests/fromRdf-manifest.html#tdi12).

## 3.0.0 - 2026-04-02

Expand Down
116 changes: 114 additions & 2 deletions lib/pyld/jsonld.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@
RDF_TYPE = RDF + 'type'
RDF_LANGSTRING = RDF + 'langString'
RDF_JSON_LITERAL = RDF + 'JSON'
RDF_VALUE = RDF + 'value'
RDF_LANGUAGE = RDF + 'language'
RDF_DIRECTION = RDF + 'direction'

# BCP47
REGEX_BCP47 = r'^[a-zA-Z]{1,8}(-[a-zA-Z0-9]{1,8})*$'
Expand Down Expand Up @@ -315,6 +318,8 @@ def from_rdf(input_, options=None):
[useRdfType] True to use rdf:type, False to use @type (default: False).
[useNativeTypes] True to convert XSD types into native types
(boolean, integer, double), False not to (default: True).
[rdfDirection] Either 'i18n-datatype' or 'compound-literal'
is supported. (default: None)

:return: the JSON-LD output.
"""
Expand Down Expand Up @@ -1000,7 +1005,8 @@ def from_rdf(self, dataset, options):
(default: False).
[useNativeTypes] True to convert XSD types into native types
(boolean, integer, double), False not to (default: False).
[rdfDirection] Only 'i18n-datatype' is supported. (default: None)
[rdfDirection] Either 'i18n-datatype' or 'compound-literal'
is supported. (default: None)

:return: the JSON-LD output.
"""
Expand Down Expand Up @@ -2976,8 +2982,14 @@ def _from_rdf(self, dataset, options):
'value': value,
}

# convert linked lists to @list arrays
for _name, graph_object in graph_map.items():
# Convert compound-literal blank nodes before list conversion, so
# list items can also contain directional value objects.
if options['rdfDirection'] == 'compound-literal':
self._rdf_direction_to_compound_literal(graph_object)

# convert linked lists to @list arrays

# no @lists to be converted, continue
if RDF_NIL not in graph_object:
continue
Expand Down Expand Up @@ -3056,6 +3068,106 @@ def _from_rdf(self, dataset, options):

return result

def _rdf_direction_to_compound_literal(self, graph_object):
"""
Replace RDF compound-literal blank nodes with JSON-LD value objects.

The RDF encoding uses a blank node with rdf:value, rdf:direction,
and optionally rdf:language. The JSON-LD form stores those fields
directly on the referencing value object, so this updates the graph
object in place just like the RDF list conversion below does.
"""
# map with blank node id to the JSON-LD value object
# that should replace references to that blank node.
compound_literals = {}
# maps for not-yet-seen blank node id to the list
# positions where a reference to it was found.
pending_references = {}

# Iterate over the graph. If a reference points to a compound literal
# already seen, replace it immediately. Otherwise, store it in
# pending_references and patch it if that target is identified later.
for id_, node in graph_object.items():
value = self._compound_literal_to_value(id_, node)
if value is not None:
compound_literals[id_] = value
# Patch any earlier references to this blank node now that we
# know it is a compound literal.
for values, index in pending_references.pop(id_, []):
values[index] = value

# Scan every array-valued property for node references. A
# compound literal can be the object of any predicate, including
# rdf:first in a list.
for key, values in node.items():
if key == '@id' or not _is_array(values):
continue
for index, item in enumerate(values):
if not _is_subject_reference(item):
continue
ref_id = item['@id']
replacement = compound_literals.get(ref_id)
if replacement is not None:
values[index] = replacement
elif ref_id.startswith('_:'):
# Only blank node references can become compound
# literals; IRI references can be ignored here.
pending_references.setdefault(ref_id, []).append((values, index))

# The encoding blank nodes are no longer graph subjects once all
# references have been rewritten.
for id_ in compound_literals:
del graph_object[id_]

def _compound_literal_to_value(self, id_, node):
"""
Return a JSON-LD value object if node has the compound-literal shape.

A valid compound literal is a blank node with only @id, rdf:value,
rdf:direction, and optionally rdf:language. Each RDF property must
have exactly one JSON-LD value-object entry.
"""
allowed_keys = {RDF_VALUE, RDF_LANGUAGE, RDF_DIRECTION, '@id'}

# Anything with extra properties is an ordinary node, not the special
# compound-literal encoding.
if (
not id_.startswith('_:')
or set(node.keys()) - allowed_keys
or not self._is_single_rdf_value(node, RDF_VALUE)
or not self._is_single_rdf_value(node, RDF_DIRECTION)
):
return None

# Start from rdf:value so datatype/native literal handling already
# done by _rdf_to_object is preserved.
value = node[RDF_VALUE][0].copy()
direction = node[RDF_DIRECTION][0].get('@value')
if direction not in ['ltr', 'rtl']:
return None

if RDF_LANGUAGE in node:
if not self._is_single_rdf_value(node, RDF_LANGUAGE):
return None
language = node[RDF_LANGUAGE][0].get('@value')
if not _is_string(language):
return None
value['@language'] = language

value['@direction'] = direction
return value

def _is_single_rdf_value(self, node, key):
"""
Return True when a node property has exactly one JSON-LD value object.
"""
return (
key in node
and _is_array(node[key])
and len(node[key]) == 1
and _is_value(node[key][0])
)

def _process_context(
self,
active_ctx,
Expand Down
3 changes: 0 additions & 3 deletions tests/runtests.py
Original file line number Diff line number Diff line change
Expand Up @@ -995,9 +995,6 @@ def write(self, filename):
'skip': {
'specVersion': ['json-ld-1.0'],
'idRegex': [
# direction (compound-literal)
'.*fromRdf-manifest#tdi11$',
'.*fromRdf-manifest#tdi12$',
# uncategorized
'.*fromRdf-manifest#t0027$',
],
Expand Down
53 changes: 53 additions & 0 deletions tests/test_jsonld.py
Original file line number Diff line number Diff line change
Expand Up @@ -925,6 +925,59 @@ def test_conflicting_property_names_in_nested_node(self):
nquads = jsonld.to_rdf(input, options={'format': 'application/n-quads'})
assert nquads == expected

class TestFromRDF:
def test_compound_literal_direction_without_language(self):
"""
Compound literals with rdf:direction should become JSON-LD value
objects when rdfDirection is compound-literal.
"""
input = """
<http://example.com/a> <http://example.org/label> _:cl1 .
_:cl1 <http://www.w3.org/1999/02/22-rdf-syntax-ns#value> "no language" .
_:cl1 <http://www.w3.org/1999/02/22-rdf-syntax-ns#direction> "rtl" .
"""

expected = [
{
'@id': 'http://example.com/a',
'http://example.org/label': [
{'@value': 'no language', '@direction': 'rtl'}
],
}
]

result = jsonld.from_rdf(input, {'rdfDirection': 'compound-literal'})

assert result == expected

def test_compound_literal_direction_with_language(self):
"""
Compound literals with rdf:language should preserve the language
when rdfDirection is compound-literal.
"""
input = """
<http://example.com/a> <http://example.org/label> _:cl1 .
_:cl1 <http://www.w3.org/1999/02/22-rdf-syntax-ns#value> "en-US" .
_:cl1 <http://www.w3.org/1999/02/22-rdf-syntax-ns#language> "en-us" .
_:cl1 <http://www.w3.org/1999/02/22-rdf-syntax-ns#direction> "rtl" .
"""

expected = [
{
'@id': 'http://example.com/a',
'http://example.org/label': [
{
'@value': 'en-US',
'@language': 'en-us',
'@direction': 'rtl',
}
],
}
]

result = jsonld.from_rdf(input, {'rdfDirection': 'compound-literal'})

assert result == expected

class TestCompact:
# Issue 59 - PR: https://github.com/digitalbazaar/pyld/pull/60
Expand Down
Loading