From 1fb700306f79538bd75f5b3993a5638670c3f638 Mon Sep 17 00:00:00 2001 From: matosys Date: Mon, 20 Jul 2026 08:02:07 +0200 Subject: [PATCH 1/3] feat(utils): introduce new `UnorderedList` object --- src/balderhub/data/lib/utils/__init__.py | 4 +- .../data/lib/utils/unordered_list.py | 43 ++++++++++ .../scenario_utils_unordered_list.py | 80 +++++++++++++++++++ 3 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 src/balderhub/data/lib/utils/unordered_list.py create mode 100644 tests/scenarios/scenario_utils_unordered_list.py diff --git a/src/balderhub/data/lib/utils/__init__.py b/src/balderhub/data/lib/utils/__init__.py index 1c2294d..f6f23b1 100644 --- a/src/balderhub/data/lib/utils/__init__.py +++ b/src/balderhub/data/lib/utils/__init__.py @@ -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', @@ -14,5 +15,6 @@ 'ResponseMessage', 'ResponseMessageList', 'SingleDataItem', - 'SingleDataItemCollection' + 'SingleDataItemCollection', + 'UnorderedList' ] diff --git a/src/balderhub/data/lib/utils/unordered_list.py b/src/balderhub/data/lib/utils/unordered_list.py new file mode 100644 index 0000000..d555ced --- /dev/null +++ b/src/balderhub/data/lib/utils/unordered_list.py @@ -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())) diff --git a/tests/scenarios/scenario_utils_unordered_list.py b/tests/scenarios/scenario_utils_unordered_list.py new file mode 100644 index 0000000..a755471 --- /dev/null +++ b/tests/scenarios/scenario_utils_unordered_list.py @@ -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 From 07724d40c96b9bd05e8ca7b44f0d0f74285bc141 Mon Sep 17 00:00:00 2001 From: matosys Date: Mon, 20 Jul 2026 08:02:42 +0200 Subject: [PATCH 2/3] feat(SingleDataItem): add support for new `UnorderedList` --- .../data/lib/utils/single_data_item.py | 21 ++- ...o_utils_single_data_item_unordered_list.py | 132 ++++++++++++++++++ 2 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 tests/scenarios/scenario_utils_single_data_item_unordered_list.py diff --git a/src/balderhub/data/lib/utils/single_data_item.py b/src/balderhub/data/lib/utils/single_data_item.py index 68b4052..42b544d 100644 --- a/src/balderhub/data/lib/utils/single_data_item.py +++ b/src/balderhub/data/lib/utils/single_data_item.py @@ -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__) @@ -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 ' @@ -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)) @@ -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]) @@ -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 @@ -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): diff --git a/tests/scenarios/scenario_utils_single_data_item_unordered_list.py b/tests/scenarios/scenario_utils_single_data_item_unordered_list.py new file mode 100644 index 0000000..1f9f93b --- /dev/null +++ b/tests/scenarios/scenario_utils_single_data_item_unordered_list.py @@ -0,0 +1,132 @@ +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.is_optional_field('unsorted_list') is False + assert NestedSingleRef.get_field_data_type('unsorted_list') == UnorderedList + assert NestedSingleRef.get_element_type_for_list('unsorted_list') == int + + assert NestedSingleRef.is_optional_field('unsorted_list_of_single_data_items') is False + assert NestedSingleRef.get_field_data_type('unsorted_list_of_single_data_items') == UnorderedList + assert NestedSingleRef.get_element_type_for_list('unsorted_list_of_single_data_items') == SimpleDataItem + + assert NestedSingleRef.is_optional_field('optional_list_of_single_data_items') is True + assert NestedSingleRef.get_field_data_type('optional_list_of_single_data_items') == UnorderedList + assert NestedSingleRef.get_element_type_for_list('optional_list_of_single_data_items') == SimpleDataItem \ No newline at end of file From 3e71a53647c139741f52497357f9cfcec59b4b3d Mon Sep 17 00:00:00 2001 From: matosys Date: Mon, 20 Jul 2026 08:49:29 +0200 Subject: [PATCH 3/3] docs(utilities): add autoclass for new `UnorderedList` --- docs/source/utilities.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/source/utilities.rst b/docs/source/utilities.rst index 82b5f3b..f237925 100644 --- a/docs/source/utilities.rst +++ b/docs/source/utilities.rst @@ -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 =================