Skip to content
Merged
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
6 changes: 6 additions & 0 deletions docs/source/utilities.rst
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ Utilities for Data Items

.. autofunction:: balderhub.data.lib.utils.functions.full_dictionary_is_not_definable


Additional / Special Types
==========================

.. autoclass:: balderhub.data.lib.utils.UnorderedList

Response Messages
=================

Expand Down
4 changes: 3 additions & 1 deletion src/balderhub/data/lib/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from .response_message_list import ResponseMessageList
from .single_data_item import SingleDataItem
from .single_data_item_collection import SingleDataItemCollection
from .unordered_list import UnorderedList

__all__ = [
'NOT_DEFINABLE',
Expand All @@ -14,5 +15,6 @@
'ResponseMessage',
'ResponseMessageList',
'SingleDataItem',
'SingleDataItemCollection'
'SingleDataItemCollection',
'UnorderedList'
]
21 changes: 18 additions & 3 deletions src/balderhub/data/lib/utils/single_data_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from .functions import convert_field_lookups_to_dict_structure
from .lookup_field_string import LookupFieldString
from .not_definable import NOT_DEFINABLE
from .unordered_list import UnorderedList

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -72,7 +73,7 @@ def _validate_element(mcs, type_def, allow_nesting=True):
if not allow_nesting:
raise MisconfiguredDataItemError('nesting typing is only allowed for list')
# references another type definition
if get_origin(type_def) in [list, List]:
if get_origin(type_def) in [list, List, UnorderedList]:
inner_args = get_args(type_def)
if len(inner_args) != 1:
raise MisconfiguredDataItemError('list type definition can only have one argument for '
Expand Down Expand Up @@ -204,7 +205,7 @@ def get_element_type_for_list(cls, field_lookup: str | LookupFieldString) -> typ
"""
cleaned_type = cls.get_cleaned_field_data_type(field_lookup)

if cls.get_field_data_type(field_lookup) != list:
if cls.get_field_data_type(field_lookup) not in [list, UnorderedList]:
raise TypeError(f'the referenced field `{field_lookup}` is no list (is from type `{cleaned_type}`)')
if cls.is_optional_field(field_lookup):
inner_args = set(get_args(cleaned_type))
Expand Down Expand Up @@ -360,6 +361,9 @@ def get_data_type(_of_element_type) -> type:
if get_origin(_of_element_type) in [list, List]:
return list

if get_origin(_of_element_type) in [UnorderedList]:
return UnorderedList

if get_origin(_of_element_type) is Optional:
return get_data_type(get_args(_of_element_type)[0])

Expand Down Expand Up @@ -591,7 +595,7 @@ def needs_to_be_checked(self_val, other_val):
f"set - self={self_value} | other={other_value}")
continue

if field_data_type is list:
if field_data_type in [list, UnorderedList]:
if allow_non_definable and self_value == NOT_DEFINABLE or other_value == NOT_DEFINABLE:
# ignore
continue
Expand All @@ -603,6 +607,17 @@ def needs_to_be_checked(self_val, other_val):
f"self={len(self_value)}, other={len(other_value)}")
else:
idx = 0

# TODO improve this implementation
if field_data_type is UnorderedList:

sorted_kwargs = {}
if issubclass(inner_type, SingleDataItem):
sorted_kwargs['key'] = lambda x: x.get_unique_identification()

self_value = sorted(self_value, **sorted_kwargs)
other_value = sorted(other_value, **sorted_kwargs)

# both lists have the same length -> start comparing items
for cur_self_item, cur_other_item in zip(self_value, other_value):
if not needs_to_be_checked(cur_self_item, cur_other_item):
Expand Down
43 changes: 43 additions & 0 deletions src/balderhub/data/lib/utils/unordered_list.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from __future__ import annotations

import copy
from typing import Any, TypeVar, get_args, get_origin

from pydantic import GetCoreSchemaHandler
from pydantic_core import core_schema

T = TypeVar("T")


class UnorderedList(list[T]):
"""
Represents a list that compares equality by its elements, regardless of their order. This class
is intended to provide functionality for unordered list comparisons, particularly useful when
order does not hold significance in operations like equality checks.
"""
def __eq__(self, other):
self_copy = copy.copy(self)
other_copy = copy.copy(other)

return self_copy.sort() == other_copy.sort()

@classmethod
def __get_pydantic_core_schema__(
cls, source_type: Any, handler: GetCoreSchemaHandler
) -> core_schema.CoreSchema:
origin = get_origin(source_type)
if origin is cls: # handles both bare UnorderedList and UnorderedList[T]
args = get_args(source_type)
if args:
# Use handler.generate_schema(...) to avoid the recursion warning
item_schema = handler.generate_schema(args[0])
return core_schema.list_schema(item_schema)

return core_schema.list_schema(handler.generate_schema(Any))

# Fallback for unexpected cases
return handler.generate_schema(list)

@classmethod
def __get_pydantic_json_schema__(cls, _core_schema, handler):
return handler(core_schema.list_schema(core_schema.any_schema()))
124 changes: 124 additions & 0 deletions tests/scenarios/scenario_utils_single_data_item_unordered_list.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
from typing import Optional
from balderhub.unit.scenarios import ScenarioUnit

from balderhub.data.lib.utils import SingleDataItem, UnorderedList


# Test data item classes for testing purposes
class SimpleDataItem(SingleDataItem):
name: str
value: int

def get_unique_identification(self):
return f"{self.name}_{self.value}"


class NestedSingleRef(SingleDataItem):
name: str
unsorted_list: UnorderedList[int]
unsorted_list_of_single_data_items: UnorderedList[SimpleDataItem]
optional_list_of_single_data_items: Optional[UnorderedList[SimpleDataItem]] = None

def get_unique_identification(self):
return self.name


class ScenarioUtilsSingleDataItemUnsortedList(ScenarioUnit):
"""Unittests for SingleDataItem class."""

def test_compare(self):
a_1 = NestedSingleRef(
name='a',
unsorted_list=UnorderedList([1, 2, 3]),
unsorted_list_of_single_data_items=UnorderedList()
)
a_2 = NestedSingleRef(
name='a',
unsorted_list=UnorderedList([1, 2, 3]),
unsorted_list_of_single_data_items=UnorderedList()
)
b = NestedSingleRef(
name='a',
unsorted_list=UnorderedList([1, 3, 2]),
unsorted_list_of_single_data_items=UnorderedList()
)
assert a_1.compare(a_2), a_1.get_difference_error_messages(a_2)

assert a_1.compare(b), a_1.get_difference_error_messages(b)
assert a_2.compare(b), a_2.get_difference_error_messages(b)

def test_compare_dataitems(self):
a = NestedSingleRef(
name='a',
unsorted_list=UnorderedList([1, 2, 3]),
unsorted_list_of_single_data_items=UnorderedList([
SimpleDataItem(name='a', value=1),
SimpleDataItem(name='b', value=2),
SimpleDataItem(name='c', value=3),
])
)
b = NestedSingleRef(
name='a', unsorted_list=UnorderedList([1, 3, 2]),
unsorted_list_of_single_data_items=UnorderedList([
SimpleDataItem(name='a', value=1),
SimpleDataItem(name='c', value=3),
SimpleDataItem(name='b', value=2),
])
)

assert a.compare(b), a.get_difference_error_messages(b)

def test_compare_empty_lists(self):
a = NestedSingleRef(
name='a',
unsorted_list=UnorderedList(),
unsorted_list_of_single_data_items=UnorderedList()
)
b = NestedSingleRef(
name='a',
unsorted_list=UnorderedList(),
unsorted_list_of_single_data_items=UnorderedList()
)
assert a.compare(b), a.get_difference_error_messages(b)

def test_optional_unsorted_list(self):

a = NestedSingleRef(
name='a',
unsorted_list=UnorderedList([1]),
unsorted_list_of_single_data_items=UnorderedList(),
optional_list_of_single_data_items=UnorderedList([SimpleDataItem(name='x', value=1)])
)
b = NestedSingleRef(
name='a',
unsorted_list=UnorderedList([1]),
unsorted_list_of_single_data_items=UnorderedList(),
optional_list_of_single_data_items=UnorderedList([SimpleDataItem(name='x', value=1)])
)
assert a.compare(b), a.get_difference_error_messages(b)

def test_compare_inequality(self):
a = NestedSingleRef(
name='a',
unsorted_list=UnorderedList([1, 2, 3]),
unsorted_list_of_single_data_items=UnorderedList()
)
b = NestedSingleRef(
name='a',
unsorted_list=UnorderedList([1, 2, 4]),
unsorted_list_of_single_data_items=UnorderedList()
)
c = NestedSingleRef(
name='a',
unsorted_list=UnorderedList([1, 2, 3, 4]),
unsorted_list_of_single_data_items=UnorderedList()
)
assert a.get_difference_error_messages(b) == ['unsorted_list[2]: detect different value - self: `[1, 2, 3]` | other: `[1, 2, 4]`'], a.get_difference_error_messages(b)
assert a.get_difference_error_messages(c) == ['unsorted_list: detect different list length - self=3, other=4'], a.get_difference_error_messages(c)
assert b.get_difference_error_messages(c) == ['unsorted_list: detect different list length - self=3, other=4'], b.get_difference_error_messages(c)


def test_get_element_type_for(self):
assert NestedSingleRef.get_field_data_type('unsorted_list') == UnorderedList
assert NestedSingleRef.get_element_type_for_list('unsorted_list') == int
assert NestedSingleRef.get_element_type_for_list('unsorted_list_of_single_data_items') == SimpleDataItem
80 changes: 80 additions & 0 deletions tests/scenarios/scenario_utils_unordered_list.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
from __future__ import annotations


from pydantic import BaseModel

from balderhub.unit.scenarios import ScenarioUnit

from balderhub.data.lib.utils import UnorderedList


class ScenarioUtilsUnorderedList(ScenarioUnit):
"""Unittests for UnorderedList class."""

def test_eq_identical_order(self):
a = UnorderedList([1, 2, 3])
b = UnorderedList([1, 2, 3])
assert a == b

def test_eq_different_order(self):
a = UnorderedList([1, 2, 3])
b = UnorderedList([3, 2, 1])
assert a == b

def test_eq_different_content(self):
a = UnorderedList([1, 2, 3])
b = UnorderedList([1, 2, 4])
assert a != b

def test_eq_empty(self):
a = UnorderedList()
b = UnorderedList()
assert a == b

def test_eq_with_list(self):
a = UnorderedList([1, 2, 3])
b = [3, 2, 1]
assert a == b

def test_pydantic_model(self):
class Model(BaseModel):
items: UnorderedList[int]

m = Model(items=[3, 1, 2])
assert isinstance(m.items, list)
assert m.items == [3, 1, 2]
assert m.model_dump() == {"items": [3, 1, 2]}

def test_pydantic_optional(self):
class Model(BaseModel):
items: UnorderedList[int] | None = None

m = Model(items=[3, 1, 2])
assert m.items == [3, 1, 2]

def test_ne_different_types(self):
a = UnorderedList([1, 2, 3])
assert a != "not a list"
assert a != 123
assert a != None
assert a != {"a": 1}

def test_ne_different_lengths(self):
a = UnorderedList([1, 2, 3])
b = UnorderedList([1, 2])
assert a != b

def test_ne_with_duplicates(self):
a = UnorderedList([1, 1, 2])
b = UnorderedList([1, 2, 2])
assert a != b

def test_eq_with_duplicates(self):
a = UnorderedList([1, 2, 1])
b = UnorderedList([2, 1, 1])
assert a == b

def test_ne_vs_plain_list_different(self):
a = UnorderedList([1, 2, 3])
b = [1, 2, 4]
assert a != b
Loading