Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
da136c3
changed ld_list according to issue #439
Oct 17, 2025
03ebd32
implemented tests for problem 2 of the issue and more tests
Oct 20, 2025
d1eb612
added a few missing conversions and formatted the file a little bit d…
Oct 20, 2025
d28dc10
removed one conversion, added one and changed _to_python to fix conve…
Oct 20, 2025
fb5aa64
fixed formatting errors
Oct 20, 2025
5248853
fixed ld_container tests, a type conversion and equals of ld_list
Oct 24, 2025
e2c1bba
added detailed comparison for ld_list, ld_dict and json_values and ad…
Nov 7, 2025
aeb88fb
added support for graph and set again and improved expansion of json-ld
Nov 21, 2025
007e3a3
removed unnecessary lambda
Nov 21, 2025
3c9ea61
fixed set and from_list and added del
Nov 21, 2025
66e03df
updated tests of ld_container and fixed found bugs
Nov 21, 2025
c4aea65
pacified flake8
Nov 21, 2025
7d30b16
improved ld_list.__contains__ and added tests for it
Nov 24, 2025
f8d259f
added one TODO item to ld_list
Nov 24, 2025
2b98627
began adding doc strings, type hints and comments for ld_container
Nov 24, 2025
1958518
improved _to_expanded_json
Nov 28, 2025
4acf8de
added doc-strings and comments and pacified flake8
Nov 28, 2025
b751130
began commenting and adding doc-strings to ld_list
Nov 28, 2025
3c243f2
fixed bug in _to_expanded_json
Dec 5, 2025
e0f0fc9
added a few test cases to delete of ld_list
Dec 5, 2025
1b5656e
added more comments to ld_list
Dec 5, 2025
071630c
added more doc strings
Dec 5, 2025
d4b34b4
corrected type hints
Dec 8, 2025
f0f1818
documented the rest of the methods in ld_container
Dec 8, 2025
bcc233d
implemented list comparison and added tests for it
Dec 12, 2025
287d37a
added tests and fixed small bug and fixed typos
Dec 15, 2025
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
50 changes: 21 additions & 29 deletions src/hermes/model/types/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,56 +21,48 @@
"ld_container": lambda c, **_: c,
"json": lambda c, **_: c.compact(),
"expanded_json": lambda c, **_: c.ld_value,
},
}
),

# Wrap item from ld_dict in ld_list
(ld_list.is_ld_list, dict(ld_container=ld_list)),
(
lambda c: isinstance(c, list) and all(isinstance(item, dict) for item in c),
dict(ld_container=lambda c, **kw: ld_list([{"@list": c}], **kw))
),
(ld_list.is_ld_list, {"ld_container": ld_list}),
(lambda c: isinstance(c, list), {"ld_container": lambda c, **kw: ld_list([{"@list": c}], **kw)}),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The side condition is important to my understanding. This rule should invoked upon an expanded JSON-LD list. In fact, the rule should be even more strict ensuring the items in the list are not only dicts but valid JSON-LD values.

Or am I getting something wrong here?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When getting a value from an ld_dict it should return an ld_list always. Therefor it is important to know wheter a given value comes from an ld_list or an ld_dict. If the value, where expanded JSON-LD is expected, is not inside a list it previously was inside a list.
I removed the condition because when getting "@type" from {"@type": ["A"]} the value in the list is a string and not a dict. (I found no cases where this goes wrong because everything is valid expanded JSON-LD.)

We should know that the values are valid expanded JSON-LD here because this conversion happens only when converting to the pythonized version of a value that was previously expanded.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, I see... that seems to be related to the removal of the explcit handling of @type in the conversion.

Well, that's fine if you can deal with the implications...

  • {"@type": "foo"} (which is valid) will always be {"@type": ["foo"]} when converted using to_python
  • The value must not be wrapped in an @list container or individually wrapped in some @value or @id node. Yet, the values are subject to IRI expansion / compaction.


# pythonize items from lists (expanded set is already handled above)
(ld_container.is_json_id, dict(python=lambda c, **_: c["@id"])),
(ld_container.is_typed_json_value, dict(python=ld_container.typed_ld_to_py)),
(ld_container.is_json_value, dict(python=lambda c, **_: c["@value"])),
(ld_list.is_container, dict(ld_container=lambda c, **kw: ld_list([c], **kw))),
(ld_dict.is_json_dict, dict(ld_container=lambda c, **kw: ld_dict([c], **kw))),
(ld_container.is_json_id, {"python": lambda c, **_: c["@id"]}),
(ld_container.is_typed_json_value, {"python": lambda c, **kw: ld_container.typed_ld_to_py([c], **kw)}),
(ld_container.is_json_value, {"python": lambda c, **_: c["@value"]}),
(ld_list.is_container, {"ld_container": lambda c, **kw: ld_list([c], **kw)}),
(ld_dict.is_json_dict, {"ld_container": lambda c, **kw: ld_dict([c], **kw)}),
(lambda v: isinstance(v, str), {"python": lambda v, parent, **_: parent.ld_proc.compact_iri(parent.active_ctx, v)}),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This rule is to generic IMHO, as it will try to compact all strings that are encountered, even those that are explicitly not meant as representing an IRI.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But shouldn't all other string should be known to be a value of some sort i.e. formed like {"@value": _} and therefor filtered by is_json_value because we get valid JSON-LD?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see you have answers... but I'm totally not sure whether this is really true.

Apart from this, except for @type values, all expansible values should be wrapped in @id nodes... maybe this is a good point to check whether this assumption is correct. The other question is, which kind of expansion is required (i.e., vocab=True or not... see discussion about prefix, namespace, IRIs from the past)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

... anyways, there should most proably be a documentation for the conversion rules and the order they are applied in (maybe directly generated from the code... ;) )


# Convert internal data types to expanded_json
(lambda c: ld_container.is_json_id(c) or ld_container.is_json_value(c), dict(expanded_json=lambda c, **_: [c])),
(ld_dict.is_json_dict, dict(expanded_json=lambda c, **kw: ld_dict.from_dict(c, **kw).ld_value)),
(ld_dict.is_ld_dict, dict(expanded_json=lambda c, **kw: ld_dict.from_dict(c[0], **kw).ld_value)),
(lambda c: ld_container.is_json_id(c), {"expanded_json": lambda c, **_: [c]}),
(lambda c: ld_container.is_ld_id(c), {"expanded_json": lambda c, **_: c}),
(lambda c: ld_container.is_json_value(c), {"expanded_json": lambda c, **_: [c]}),
(lambda c: ld_container.is_ld_value(c), {"expanded_json": lambda c, **_: c}),
(ld_dict.is_json_dict, {"expanded_json": lambda c, **kw: ld_dict.from_dict(c, **kw).ld_value}),
(
ld_list.is_container,
dict(
expanded_json=lambda c, **kw: ld_list.from_list(
ld_list([c]).item_list, container=ld_list([c]).container, **kw
).ld_value
),
{"expanded_json": lambda c, **kw: ld_list.from_list(ld_list.get_item_list_from_container(c), **kw).ld_value}
),
(
ld_list.is_ld_list,
dict(
expanded_json=lambda c, **kw: ld_list.from_list(
ld_list(c).item_list, container=ld_list(c).container, **kw
).ld_value
),
{"expanded_json": lambda c, **kw: ld_list.from_list(ld_list.get_item_list_from_container(c[0]), **kw).ld_value}
),
(lambda c: isinstance(c, list), dict(expanded_json=lambda c, **kw: ld_list.from_list(c, **kw).ld_value)),
(lambda v: isinstance(v, (int, float, str, bool)), dict(expanded_json=lambda v, **_: [{"@value": v}])),
(lambda c: isinstance(c, list), {"expanded_json": lambda c, **kw: ld_list.from_list(c, **kw).ld_value}),
(lambda v: isinstance(v, (int, float, str, bool)), {"expanded_json": lambda v, **_: [{"@value": v}]}),
(
lambda v: isinstance(v, datetime),
dict(expanded_json=lambda v, **_: [{"@value": v.isoformat(), "@type": iri_map["schema:DateTime"]}]),
{"expanded_json": lambda v, **_: [{"@value": v.isoformat(), "@type": iri_map["schema:DateTime"]}]}
),
(
lambda v: isinstance(v, date),
dict(expanded_json=lambda v, **_: [{"@value": v.isoformat(), "@type": iri_map["schema:Date"]}]),
{"expanded_json": lambda v, **_: [{"@value": v.isoformat(), "@type": iri_map["schema:Date"]}]}
),
(
lambda v: isinstance(v, time),
dict(expanded_json=lambda v, **_: [{"@value": v.isoformat(), "@type": iri_map["schema:Time"]}]),
{"expanded_json": lambda v, **_: [{"@value": v.isoformat(), "@type": iri_map["schema:Time"]}]}
),
]

Expand Down
18 changes: 11 additions & 7 deletions src/hermes/model/types/ld_container.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,13 +83,6 @@ def ld_value(self):
def _to_python(self, full_iri, ld_value):
if full_iri == "@id":
value = self.ld_proc.compact_iri(self.active_ctx, ld_value, vocab=False)
elif full_iri == "@type":
value = [
self.ld_proc.compact_iri(self.active_ctx, ld_type)
for ld_type in ld_value
]
if len(value) == 1:
value = value[0]
Comment on lines -86 to -92

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure what by my guts say this will break stuff in an unexpected way. Not sure why (and hence there should be better documentation) but I remember removing and adding this section over and over again...

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see hint above... this will break conversion of single type classes which is valid but which was already critizised towards me (or maybe there were even real errors... but I doubt it)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't found anything it breaks yet, e.g. no failing tests, but it took some effort to get this running.

else:
value, ld_output = self.ld_proc.apply_typemap(ld_value, "python", "ld_container",
parent=self, key=full_iri)
Expand Down Expand Up @@ -189,3 +182,14 @@ def typed_ld_to_py(cls, data, **kwargs):
ld_value = data[0]['@value']

return ld_value

@classmethod
def are_values_equal(cls, first, second):
if "@id" in first and "@id" in second:
return first["@id"] == second["@id"]
for key in {"@value", "@type"}:
if (key in first) ^ (key in second):
return False
if key in first and key in second and first[key] != second[key]:
return False
return True
37 changes: 37 additions & 0 deletions src/hermes/model/types/ld_dict.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
# SPDX-License-Identifier: Apache-2.0

# SPDX-FileContributor: Michael Meinel
# SPDX-FileContributor: Michael Fritzsche

from .ld_container import ld_container

Expand Down Expand Up @@ -35,6 +36,42 @@ def __contains__(self, key):
full_iri = self.ld_proc.expand_iri(self.active_ctx, key)
return full_iri in self.data_dict

def __eq__(self, other):
if not isinstance(other, (dict, ld_dict)):
return NotImplemented
if ld_container.is_json_id(other):
if "@id" in self:
return self["@id"] == other["@id"]
return self.data_dict == {}
if ld_container.is_json_value(other):
if {*self.keys()}.issubset({"@id", *other.keys()}):
return ld_container.are_values_equal(self.data_dict, other)
return False
if isinstance(other, dict):
other = self.from_dict(other, parent=self.parent, key=self.key, context=self.context)
if "@id" in self and "@id" in other:
return self["@id"] == other["@id"]
keys_self = {*self.keys()}
keys_other = {*other.keys()}
unique_keys = keys_self.symmetric_difference(keys_other)
if unique_keys and unique_keys != {"@id"}:
return False
for key in keys_self.intersection(keys_other):
item = self[key]
other_item = other[key]
res = item.__eq__(other_item)
if res == NotImplemented:
res = other_item.__eq__(item)
if res is False or res == NotImplemented: # res is not True
return False
return True

def __ne__(self, other):
x = self.__eq__(other)
if x is NotImplemented:
return NotImplemented
return not x

def get(self, key, default=_NO_DEFAULT):
try:
value = self[key]
Expand Down
84 changes: 44 additions & 40 deletions src/hermes/model/types/ld_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,20 @@
# SPDX-License-Identifier: Apache-2.0

# SPDX-FileContributor: Michael Meinel
# SPDX-FileContributor: Michael Fritzsche

from .ld_container import ld_container


class ld_list(ld_container):
""" An JSON-LD container resembling a list. """

container_types = ['@list', '@set', '@graph']

def __init__(self, data, *, parent=None, key=None, index=None, context=None):
""" Create a new ld_list.py container.

# FIXME: #439 there is no parameter container
:param container: The container type for this list.
"""
# FIXME: #439 A set container does not contain "@set" in the expected data format (expanded json ld)
# Instead it is just a list of dicts and therefor would raise a ValueError here (and fail ld_list.is_ld_list)

if not (self.is_ld_list(data) and "@list" in data[0]):
raise ValueError("The given data does not represent a ld_list.")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See my comments on the issue: Don't restrict it to @list here but better rename the class itself.

And please, bring back the docstring!

@notactuallyfinn notactuallyfinn Nov 10, 2025 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It wasn't about the name of the class but rather that it didn't work for @set's as their expanded version does not contain @set. Additionally inserting one collection into another has to be treated differently depending on what container type they have and every (python) list could be considered a set which makes all the checks wheter something is a list or not useless and the type conversions even more complicated. But that will be discussed in the issue.

Will do.

super().__init__(data, parent=parent, key=key, index=index, context=context)

# Determine container and correct item list
for container in self.container_types:
if container in self._data[0]:
self.item_list = self._data[0][container]
self.container = container
break
else:
raise ValueError(f"Unexpected dict: {data}")
self.item_list = data[0]["@list"]

def __getitem__(self, index):
if isinstance(index, slice):
Expand All @@ -42,18 +28,6 @@ def __getitem__(self, index):
return item

def __setitem__(self, index, value):
# FIXME: #439 what should your_ld_list[index] = [{"@type": "foo", "name": "bar"}] mean?
# set your_ld_list[index] to the dict {"@type": "foo", "name": "bar"} given in expanded form or
# set your_ld_list[index] to the list [{"@type": "foo", "name": "bar"}] given in non expanded form or
# set your_ld_list[index] to the set [{"@type": "foo", "name": "bar"}] given in expanded form
# (ld_list.fromlist([{"@type": "foo", "name": "bar"}]) defaults to container type list
# which would have the object as an expanded form whereas the expanded form of a list would be
# ["@list": [{"@type": "foo", "name": "bar"}]]
# This is relevent because nested sets get unnested when being expanded and lists not.
# Moreover a set inside a list gets automaticaly converted to a list when expanded)

# FIXME: #439 what happens when a ld_list is put inside another also depends on their container types

if not isinstance(index, slice):
self.item_list[index] = val[0] if isinstance(val := self._to_expanded_json(self.key, value), list) else val
return
Expand All @@ -79,12 +53,36 @@ def __contains__(self, value):
return expanded_value in self.item_list

def __eq__(self, other):
if isinstance(other, ld_list):
# FIXME: #439 When are ld_lists equal?
return self.item_list == other.item_list and self.container == other.container
if not (isinstance(other, (list, ld_list)) or ld_list.is_container(other)):
return NotImplemented
if isinstance(other, dict):
other = [other]
if isinstance(other, list):
return self.item_list == self._to_expanded_json(self.key, other)[0]["@list"]
return NotImplemented
if ld_list.is_ld_list(other):
other = ld_list.get_item_list_from_container(other[0])
other = self.from_list(other, parent=self.parent, key=self.key, context=self.context)
if len(self.item_list) != len(other.item_list):
return False
if (self.key == "@type") ^ (other.key == "@type"):
return False
if self.key == other.key == "@type":
return self.item_list == other.item_list
for index, (item, other_item) in enumerate(zip(self.item_list, other.item_list)):
if ((ld_container.is_typed_json_value(item) or ld_container.is_json_value(item)) and
(ld_container.is_typed_json_value(other_item) or ld_container.is_json_value(other_item))):
if not ld_container.are_values_equal(item, other_item):
return False
continue
if "@id" in item and "@id" in other_item:
return item["@id"] == other_item["@id"]
item = self[index]
other_item = other[index]
res = item.__eq__(other_item)
if res == NotImplemented:
res = other_item.__eq__(item)
if res is False or res == NotImplemented: # res is not True
return False
return True

def __ne__(self, other):
x = self.__eq__(other)
Expand All @@ -108,19 +106,25 @@ def to_python(self):

@classmethod
def is_ld_list(cls, ld_value):
# FIXME: #439 every python list that contains at least one dict can be considerd a set in expanded json form
return cls.is_ld_node(ld_value) and cls.is_container(ld_value[0])

@classmethod
def is_container(cls, value):
# FIXME: #439 "@set" will never be inside a dictionary of an expanded json ld object
return (
isinstance(value, dict)
and len([1 for ct in cls.container_types if isinstance(value.get(ct, None), list)]) == 1
and [*value.keys()] in [["@list"], ["@set"], ["@graph"]]
and any(isinstance(value.get(cont, None), list) for cont in {"@list", "@set", "@graph"})
)

@classmethod
def from_list(cls, value, *, parent=None, key=None, context=None, container=None):
new_list = cls([{container or "@list": []}], parent=parent, key=key, context=context)
def from_list(cls, value, *, parent=None, key=None, context=None):
new_list = cls([{"@list": []}], parent=parent, key=key, context=context)
new_list.extend(value)
return new_list

@classmethod
def get_item_list_from_container(cls, ld_value):
for cont in {"@list", "@set", "@graph"}:
if cont in ld_value:
return ld_value[cont]
raise ValueError("The given data does not represent a container.")
28 changes: 19 additions & 9 deletions test/hermes_test/model/types/test_ld_container.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

# SPDX-FileContributor: Sophie Kernchen
# SPDX-FileContributor: Michael Meinel
# SPDX-FileContributor: Michael Fritzsche

from datetime import datetime

Expand Down Expand Up @@ -96,27 +97,26 @@ def test_to_python_id_with_prefix(self, mock_context):

def test_to_python_type(self, mock_context):
cont = ld_container([{}], context=[mock_context])
assert cont._to_python("@type", ["@id"]) == '@id'
assert cont._to_python("@type", ["@id"]) == ['@id']
assert cont._to_python("@type", ["@id", "http://spam.eggs/Egg"]) == ["@id", "Egg"]

def test_to_python_id_value(self, mock_context):
cont = ld_container([{}], context=[mock_context])
assert cont._to_python("http://spam.eggs/ham", [{"@id": "http://spam.eggs/spam"}]) == ["http://spam.eggs/spam"]
assert cont._to_python("http://spam.eggs/ham",
[{"@id": "http://spam.eggs/spam"}]) == "http://spam.eggs/spam"
assert cont._to_python("http://spam.eggs/ham",
[{"@id": "http://spam.eggs/identifier"}]) == "http://spam.eggs/identifier"
{"@id": "http://spam.eggs/identifier"}) == "http://spam.eggs/identifier"

def test_to_python_basic_value(self, mock_context):
cont = ld_container([{}], context=[mock_context])
assert cont._to_python("http://soam.eggs/spam", [{"@value": "bacon"}]) == 'bacon'
assert cont._to_python("http://spam.eggs/spam", [{"@value": True}]) is True
assert cont._to_python("http://spam.eggs/spam", [{"@value": 123}]) == 123
assert cont._to_python("http://soam.eggs/spam", {"@value": "bacon"}) == 'bacon'
assert cont._to_python("http://spam.eggs/spam", {"@value": True}) is True
assert cont._to_python("http://spam.eggs/spam", {"@value": 123}) == 123

def test_to_python_datetime_value(self, mock_context):
cont = ld_container([{}], context=[mock_context])
assert cont._to_python("http://spam.eggs/eggs", [{
assert cont._to_python("http://spam.eggs/eggs", {
"@value": "2022-02-22T00:00:00", "@type": "https://schema.org/DateTime"
}]) == "2022-02-22T00:00:00"
}) == "2022-02-22T00:00:00" # TODO: #434 typed date is returned as string instead of date

def test_to_expanded_id(self, mock_context):
cont = ld_container([{}], context=[mock_context])
Expand Down Expand Up @@ -153,3 +153,13 @@ def test_to_expanded_datetime_value(self, mock_context):
assert cont._to_expanded_json("eggs", datetime(2022, 2, 22)) == [
{"@value": "2022-02-22T00:00:00", "@type": "http://schema.org/DateTime"}
]

def test_are_values_equal(self):
assert ld_container.are_values_equal({"@id": "foo"}, {"@id": "foo"})
assert not ld_container.are_values_equal({"@id": "foo"}, {"@id": "bar"})
assert ld_container.are_values_equal({"@id": "foo"}, {"@id": "foo", "@value": "bar"})
assert ld_container.are_values_equal({"@value": "foo"}, {"@value": "foo"})
assert ld_container.are_values_equal({"@value": "bar"}, {"@id": "foo", "@value": "bar"})
assert not ld_container.are_values_equal({"@value": "foo"}, {"@value": "bar"})
assert not ld_container.are_values_equal({"@type": "bar", "@value": "foo"}, {"@value": "foo"})
assert ld_container.are_values_equal({"@type": "bar", "@value": "foo"}, {"@type": "bar", "@value": "foo"})
26 changes: 26 additions & 0 deletions test/hermes_test/model/types/test_ld_dict.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# SPDX-FileCopyrightText: 2025 German Aerospace Center (DLR)
#
# SPDX-License-Identifier: Apache-2.0

# SPDX-FileContributor: Michael Fritzsche

from hermes.model.types.ld_dict import ld_dict


def test_build_in_comparison():
di = ld_dict([{}], context={"schema": "https://schema.org/"})
assert di != 1 and di != [] and di != ""
di["@id"] = "foo"
di["schema:name"] = "bar"
assert di == {"@id": "foo"}
# Fail probably because of bug in ld_dict
# that is fixed on refactor/data-model after merge of refactor/384-test-ld_dict
assert di == {"@id": "foo", "schema:name": "bar"}
assert di == {"@id": "foo", "name": "b"}
assert di == {"schema:name": "bar"}
di = ld_dict([{}], context={"schema": "https://schema.org/"})
di["schema:Person"] = {"schema:name": "foo"}
assert di == {"schema:Person": {"schema:name": "foo"}}
di["schema:Person"].append({"schema:name": "bar"})
assert di == {"schema:Person": [{"schema:name": "foo"}, {"schema:name": "bar"}]}
assert di != {"schema:name": "foo"}
Loading
Loading