From 3306c3cc6cb8e79a73c52afb807eb7f6ac842c02 Mon Sep 17 00:00:00 2001 From: Christian Dam Vedel Date: Thu, 5 Feb 2026 17:30:31 +0100 Subject: [PATCH 1/7] Add initial EasyList class --- src/easyscience/base_classes/__init__.py | 2 + src/easyscience/base_classes/easy_list.py | 267 ++++++++++++++++++++++ 2 files changed, 269 insertions(+) create mode 100644 src/easyscience/base_classes/easy_list.py diff --git a/src/easyscience/base_classes/__init__.py b/src/easyscience/base_classes/__init__.py index 9f3ba080..2d13a3ba 100644 --- a/src/easyscience/base_classes/__init__.py +++ b/src/easyscience/base_classes/__init__.py @@ -1,5 +1,6 @@ from .based_base import BasedBase from .collection_base import CollectionBase +from .easy_list import EasyList from .model_base import ModelBase from .new_base import NewBase from .obj_base import ObjBase @@ -10,4 +11,5 @@ ObjBase, ModelBase, NewBase, + EasyList ] diff --git a/src/easyscience/base_classes/easy_list.py b/src/easyscience/base_classes/easy_list.py new file mode 100644 index 00000000..aa6e8edd --- /dev/null +++ b/src/easyscience/base_classes/easy_list.py @@ -0,0 +1,267 @@ +# SPDX-FileCopyrightText: 2025 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +# © 2021-2025 Contributors to the EasyScience project ProtectedType_: ... + @overload + def __getitem__(self, idx: slice) -> 'EasyList[ProtectedType_]': ... + @overload + def __getitem__(self, idx: str) -> ProtectedType_: ... + def __getitem__(self, idx: int | slice | str) -> ProtectedType_ | 'EasyList[ProtectedType_]': + """ + Get an item by index, slice, or unique_name. + + :param idx: Index, slice, or unique_name of the item + :return: The item or a new EasyList for slices + """ + if isinstance(idx, int): + return self._data[idx] + elif isinstance(idx, slice): + return self.__class__(self._data[idx], protected_types=self._protected_types) + elif isinstance(idx, str): + element = next((r for r in self._data if r.unique_name == idx), None) + if element is not None: + return element + raise KeyError(f'No item with unique name "{idx}" found') + else: + raise TypeError('Index must be an int, slice, or str') + + @overload + def __setitem__(self, idx: int, value: ProtectedType_) -> None: ... + @overload + def __setitem__(self, idx: slice, value: Iterable[ProtectedType_]) -> None: ... + + def __setitem__(self, idx: int | slice, value: ProtectedType_ | Iterable[ProtectedType_]) -> None: + """ + Set an item at an index. + + :param idx: Index to set + :param value: New value + """ + if isinstance(idx, int): + if not isinstance(value, tuple(self._protected_types)): + raise TypeError(f'Items must be one of {self._protected_types}, got {type(value)}') + self._data[idx] = value + elif isinstance(idx, slice): + if not isinstance(value, Iterable): + raise TypeError('Value must be an iterable for slice assignment') + for v in value: + if not isinstance(v, tuple(self._protected_types)): + raise TypeError(f'Items must be one of {self._protected_types}, got {type(v)}') + self._data[idx] = list(value) # type: ignore[arg-type] + else: + raise TypeError('Index must be an int or slice') + + def __delitem__(self, idx: int | slice | str) -> None: + """ + Delete an item by index, slice, or name. + + :param idx: Index, slice, or name of item to delete + """ + if isinstance(idx, (int, slice)): + del self._data[idx] + elif isinstance(idx, str): + for i, item in enumerate(self._data): + if item.unique_name == idx: + del self._data[i] + return + raise KeyError(f'No item with unique name "{idx}" found') + else: + raise TypeError('Index must be an int, slice, or str') + + def __len__(self) -> int: + """Return the number of items in the collection.""" + return len(self._data) + + def insert(self, index: int, value: ProtectedType_) -> None: + """ + Insert an item at an index. + + :param index: Index to insert at + :param value: Item to insert + """ + if not isinstance(index, int): + raise TypeError('Index must be an integer') + elif not isinstance(value, tuple(self._protected_types)): + raise TypeError(f'Items must be one of {self._protected_types}, got {type(value)}') + + self._data.insert(index, value) + + # Overwriting methods + + def sort(self, mapping: Callable[[ProtectedType_], Any], reverse: bool = False) -> None: + """ + Sort the collection according to the given mapping. + + :param mapping: Mapping function to sort by + :param reverse: Whether to reverse the sort + """ + self._data.sort(key=mapping, reverse=reverse) # type: ignore[arg-type] + + def __repr__(self) -> str: + return f'{self.__class__.__name__} of length {len(self)} of type(s) {self._protected_types}' + + def __iter__(self) -> Any: + return iter(self._data) + + def __contains__(self, item: ProtectedType_ | str) -> bool: + if isinstance(item, str): + return any(r.unique_name == item for r in self._data) + return item in self._data + + def index(self, value: ProtectedType_ | str, start: int = 0, stop: int = ...) -> int: + if isinstance(value, str): + for i in range(start, min(stop, len(self._data))): + if self._data[i].unique_name == value: + return i + raise ValueError(f'{value} is not in EasyList') + return self._data.index(value, start, stop) + + def append(self, value: ProtectedType_) -> None: + """ + Append an item to the end of the collection. + + :param value: Item to append + """ + if not isinstance(value, tuple(self._protected_types)): + raise TypeError(f'Items must be one of {self._protected_types}, got {type(value)}') + self._data.append(value) + + def pop(self, index: int | str = -1) -> ProtectedType_: + """ + Remove and return an item at the given index or unique_name. + + :param index: Index or unique_name of the item to remove + :return: The removed item + """ + if isinstance(index, int): + return self._data.pop(index) + elif isinstance(index, str): + for i, item in enumerate(self._data): + if item.unique_name == index: + return self._data.pop(i) + raise KeyError(f'No item with unique name "{index}" found') + else: + raise TypeError('Index must be an int or str') + + # Serialization support + + def to_dict(self) -> dict: + """ + Convert the EasyList to a dictionary for serialization. + + :return: Dictionary representation of the EasyList + """ + dict_repr = super().to_dict() + if self._protected_types != [NewBase]: + dict_repr['protected_types'] = [ + {'@module': cls_.__module__, '@class': cls_.__name__} for cls_ in self._protected_types + ] # noqa: E501 + dict_repr['data'] = [item.to_dict() for item in self._data] + return dict_repr + + @classmethod + def from_dict(cls, obj_dict: Dict[str, Any]) -> NewBase: + """ + Re-create an EasyScience object from a full encoded dictionary. + + :param obj_dict: dictionary containing the serialized contents (from `SerializerDict`) of an EasyScience object + :return: Reformed EasyScience object + """ + if not SerializerBase._is_serialized_easyscience_object(obj_dict): + raise ValueError('Input must be a dictionary representing an EasyScience EasyList object.') + if obj_dict['@class'] == cls.__name__: + if 'protected_types' in obj_dict: + protected_types = obj_dict.pop('protected_types') + for i, type_dict in enumerate(protected_types): + if '@module' in type_dict and '@class' in type_dict: + modname = type_dict['@module'] + classname = type_dict['@class'] + mod = __import__(modname, globals(), locals(), [classname], 0) + if hasattr(mod, classname): + cls_ = getattr(mod, classname) + protected_types[i] = cls_ + else: + raise ImportError(f'Could not import class {classname} from module {modname}') + else: + raise ValueError( + 'Each protected type must be a serialized EasyScience class with @module and @class keys' + ) # noqa: E501 + else: + protected_types = None + kwargs = SerializerBase.deserialize_dict(obj_dict) + data = kwargs.pop('data', []) + return cls(data, protected_types=protected_types, **kwargs) + else: + raise ValueError(f'Class name in dictionary does not match the expected class: {cls.__name__}.') From b0ec7ba9d05de9371b8004ea3200bcd0709fb9c4 Mon Sep 17 00:00:00 2001 From: Christian Vedel Date: Wed, 18 Feb 2026 17:25:00 +0100 Subject: [PATCH 2/7] Implement internal _get_key to easily override for custom key access --- src/easyscience/base_classes/easy_list.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/easyscience/base_classes/easy_list.py b/src/easyscience/base_classes/easy_list.py index aa6e8edd..03b6cd59 100644 --- a/src/easyscience/base_classes/easy_list.py +++ b/src/easyscience/base_classes/easy_list.py @@ -90,7 +90,7 @@ def __getitem__(self, idx: int | slice | str) -> ProtectedType_ | 'EasyList[Prot elif isinstance(idx, slice): return self.__class__(self._data[idx], protected_types=self._protected_types) elif isinstance(idx, str): - element = next((r for r in self._data if r.unique_name == idx), None) + element = next((r for r in self._data if self._get_key(r) == idx), None) if element is not None: return element raise KeyError(f'No item with unique name "{idx}" found') @@ -133,7 +133,7 @@ def __delitem__(self, idx: int | slice | str) -> None: del self._data[idx] elif isinstance(idx, str): for i, item in enumerate(self._data): - if item.unique_name == idx: + if self._get_key(item) == idx: del self._data[i] return raise KeyError(f'No item with unique name "{idx}" found') @@ -158,6 +158,17 @@ def insert(self, index: int, value: ProtectedType_) -> None: self._data.insert(index, value) + def _get_key(self, object, attribute='unique_name') -> str: + """ + Get the unique name of an object. + Can be overridden to use a different attribute as the key. + :param object: Object to get the key for + :param attribute: Attribute to use as the key (default is 'unique_name') + :return: The key of the object + :rtype: str + """ + return getattr(object, attribute) + # Overwriting methods def sort(self, mapping: Callable[[ProtectedType_], Any], reverse: bool = False) -> None: @@ -177,13 +188,13 @@ def __iter__(self) -> Any: def __contains__(self, item: ProtectedType_ | str) -> bool: if isinstance(item, str): - return any(r.unique_name == item for r in self._data) + return any(self._get_key(r) == item for r in self._data) return item in self._data def index(self, value: ProtectedType_ | str, start: int = 0, stop: int = ...) -> int: if isinstance(value, str): for i in range(start, min(stop, len(self._data))): - if self._data[i].unique_name == value: + if self._get_key(self._data[i]) == value: return i raise ValueError(f'{value} is not in EasyList') return self._data.index(value, start, stop) @@ -209,7 +220,7 @@ def pop(self, index: int | str = -1) -> ProtectedType_: return self._data.pop(index) elif isinstance(index, str): for i, item in enumerate(self._data): - if item.unique_name == index: + if self._get_key(item) == index: return self._data.pop(i) raise KeyError(f'No item with unique name "{index}" found') else: From df93614dac24c1a88bf103995b4ef99aa8995865 Mon Sep 17 00:00:00 2001 From: Christian Vedel Date: Wed, 18 Feb 2026 17:51:58 +0100 Subject: [PATCH 3/7] Don't allow multiple instances of objects in EasyList --- src/easyscience/base_classes/easy_list.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/easyscience/base_classes/easy_list.py b/src/easyscience/base_classes/easy_list.py index 03b6cd59..de64bd6f 100644 --- a/src/easyscience/base_classes/easy_list.py +++ b/src/easyscience/base_classes/easy_list.py @@ -4,6 +4,7 @@ from __future__ import annotations +import warnings from collections.abc import MutableSequence from typing import TYPE_CHECKING from typing import Any @@ -112,6 +113,9 @@ def __setitem__(self, idx: int | slice, value: ProtectedType_ | Iterable[Protect if isinstance(idx, int): if not isinstance(value, tuple(self._protected_types)): raise TypeError(f'Items must be one of {self._protected_types}, got {type(value)}') + if value in self: + warnings.warn(f'Item with unique name "{self._get_key(value)}" already in EasyList, it will be ignored') + return self._data[idx] = value elif isinstance(idx, slice): if not isinstance(value, Iterable): @@ -119,6 +123,9 @@ def __setitem__(self, idx: int | slice, value: ProtectedType_ | Iterable[Protect for v in value: if not isinstance(v, tuple(self._protected_types)): raise TypeError(f'Items must be one of {self._protected_types}, got {type(v)}') + if v in self: + warnings.warn(f'Item with unique name "{self._get_key(v)}" already in EasyList, it will be ignored') + return self._data[idx] = list(value) # type: ignore[arg-type] else: raise TypeError('Index must be an int or slice') @@ -155,19 +162,20 @@ def insert(self, index: int, value: ProtectedType_) -> None: raise TypeError('Index must be an integer') elif not isinstance(value, tuple(self._protected_types)): raise TypeError(f'Items must be one of {self._protected_types}, got {type(value)}') - + if value in self: + warnings.warn(f'Item with unique name "{self._get_key(value)}" already in EasyList, it will be ignored') + return self._data.insert(index, value) - def _get_key(self, object, attribute='unique_name') -> str: + def _get_key(self, object) -> str: """ Get the unique name of an object. Can be overridden to use a different attribute as the key. :param object: Object to get the key for - :param attribute: Attribute to use as the key (default is 'unique_name') :return: The key of the object :rtype: str """ - return getattr(object, attribute) + return object.unique_name # Overwriting methods @@ -207,6 +215,9 @@ def append(self, value: ProtectedType_) -> None: """ if not isinstance(value, tuple(self._protected_types)): raise TypeError(f'Items must be one of {self._protected_types}, got {type(value)}') + if value in self: + warnings.warn(f'Item with unique name "{self._get_key(value)}" already in EasyList, it will be ignored') + return self._data.append(value) def pop(self, index: int | str = -1) -> ProtectedType_: From 511c8b317903424f84418038605d30f823252c9a Mon Sep 17 00:00:00 2001 From: Christian Vedel Date: Thu, 19 Feb 2026 10:50:45 +0100 Subject: [PATCH 4/7] Claude unit tests --- .../unit_tests/base_classes/test_easy_list.py | 457 ++++++++++++++++++ 1 file changed, 457 insertions(+) create mode 100644 tests/unit_tests/base_classes/test_easy_list.py diff --git a/tests/unit_tests/base_classes/test_easy_list.py b/tests/unit_tests/base_classes/test_easy_list.py new file mode 100644 index 00000000..c32cde9e --- /dev/null +++ b/tests/unit_tests/base_classes/test_easy_list.py @@ -0,0 +1,457 @@ +# SPDX-FileCopyrightText: 2025 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +# © 2021-2025 Contributors to the EasyScience project Date: Fri, 20 Feb 2026 14:33:54 +0100 Subject: [PATCH 5/7] Fix reverse and ellipsis error in EasyList --- src/easyscience/base_classes/easy_list.py | 27 ++++++++----- .../unit_tests/base_classes/test_easy_list.py | 40 ++++++++++++++----- 2 files changed, 46 insertions(+), 21 deletions(-) diff --git a/src/easyscience/base_classes/easy_list.py b/src/easyscience/base_classes/easy_list.py index de64bd6f..1a9a7081 100644 --- a/src/easyscience/base_classes/easy_list.py +++ b/src/easyscience/base_classes/easy_list.py @@ -29,6 +29,8 @@ class EasyList(NewBase, MutableSequence[ProtectedType_]): + # If we were to inherit from List instead of MutableSequence, + # we would have to overwrite "extend", "remove", "__iadd__", "count" and "clear" def __init__( self, *args: ProtectedType_ | list[ProtectedType_], @@ -179,15 +181,6 @@ def _get_key(self, object) -> str: # Overwriting methods - def sort(self, mapping: Callable[[ProtectedType_], Any], reverse: bool = False) -> None: - """ - Sort the collection according to the given mapping. - - :param mapping: Mapping function to sort by - :param reverse: Whether to reverse the sort - """ - self._data.sort(key=mapping, reverse=reverse) # type: ignore[arg-type] - def __repr__(self) -> str: return f'{self.__class__.__name__} of length {len(self)} of type(s) {self._protected_types}' @@ -198,8 +191,22 @@ def __contains__(self, item: ProtectedType_ | str) -> bool: if isinstance(item, str): return any(self._get_key(r) == item for r in self._data) return item in self._data + + def __reversed__(self): + return self._data.__reversed__() + + def sort(self, mapping: Callable[[ProtectedType_], Any], reverse: bool = False) -> None: + """ + Sort the collection according to the given mapping. + + :param mapping: Mapping function to sort by + :param reverse: Whether to reverse the sort + """ + self._data.sort(key=mapping, reverse=reverse) # type: ignore[arg-type] - def index(self, value: ProtectedType_ | str, start: int = 0, stop: int = ...) -> int: + def index(self, value: ProtectedType_ | str, start: int = 0, stop: int = None) -> int: + if stop is None: + stop = len(self._data) if isinstance(value, str): for i in range(start, min(stop, len(self._data))): if self._get_key(self._data[i]) == value: diff --git a/tests/unit_tests/base_classes/test_easy_list.py b/tests/unit_tests/base_classes/test_easy_list.py index c32cde9e..eae484a6 100644 --- a/tests/unit_tests/base_classes/test_easy_list.py +++ b/tests/unit_tests/base_classes/test_easy_list.py @@ -184,11 +184,13 @@ def test_setitem_slice_non_iterable_raises(self): # --- __delitem__ --- def test_delitem_int(self, populated_list): + assert len(populated_list) == 3 del populated_list[0] assert len(populated_list) == 2 assert populated_list[0].unique_name == 'a2' def test_delitem_slice(self, populated_list): + assert len(populated_list) == 3 del populated_list[0:2] assert len(populated_list) == 1 assert populated_list[0].unique_name == 'a3' @@ -297,11 +299,20 @@ def test_not_contains_by_str(self): el = EasyList(a1, protected_types=Alpha) assert 'nonexistent' not in el + # --- reverse --- + def test_reverse(self): + a1 = Alpha(unique_name='a1') + a2 = Alpha(unique_name='a2') + el = EasyList(a1, a2, protected_types=Alpha) + reversed_el = list(reversed(el)) + assert reversed_el[0].unique_name == 'a2' + assert reversed_el[1].unique_name == 'a1' + # --- index --- def test_index_by_object(self, populated_list): item = populated_list[1] - assert populated_list.index(item, 0, len(populated_list)) == 1 + assert populated_list.index(item) == 1 def test_index_by_str(self, populated_list): assert populated_list.index('a2', 0, 3) == 1 @@ -315,6 +326,10 @@ def test_index_by_object_not_found(self, populated_list): with pytest.raises(ValueError): populated_list.index(other, 0, len(populated_list)) + def test_index_not_in_range(self, populated_list): + with pytest.raises(ValueError): + populated_list.index('a2', 2, 3) + # --- pop --- def test_pop_default_last(self, populated_list): @@ -387,6 +402,7 @@ def test_repr(self): assert 'Alpha' in r # --- MutableSequence behavior --- + # If we were to inherit from List instead of MutableSequence, we would have to implement all of these manually. def test_extend(self): a1 = Alpha(unique_name='a1') @@ -395,12 +411,11 @@ def test_extend(self): el.extend([a1, a2]) assert len(el) == 2 - def test_remove_via_del(self): - """Test removing an item by name using del (remove relies on index which has a default stop=Ellipsis bug).""" + def test_remove(self): a1 = Alpha(unique_name='a1') a2 = Alpha(unique_name='a2') el = EasyList(a1, a2, protected_types=Alpha) - del el['a1'] + el.remove(a1) assert len(el) == 1 assert el[0].unique_name == 'a2' @@ -411,21 +426,24 @@ def test_iadd(self): el += [a2] assert len(el) == 2 - def test_reverse_via_sort(self): - """Test reversing using sort (MutableSequence.reverse uses __setitem__ which triggers - uniqueness guard, so we test reverse ordering via sort instead).""" + def test_iadd_easylist(self): a1 = Alpha(unique_name='a1') a2 = Alpha(unique_name='a2') - el = EasyList(a1, a2, protected_types=Alpha) - el.sort(mapping=lambda x: x.unique_name, reverse=True) - assert el[0].unique_name == 'a2' - assert el[1].unique_name == 'a1' + el = EasyList(a1, protected_types=Alpha) + el += EasyList(a2, protected_types=Alpha) + assert len(el) == 2 def test_count(self): a1 = Alpha(unique_name='a1') el = EasyList(a1, protected_types=Alpha) assert el.count(a1) == 1 + def test_clear(self): + a1 = Alpha(unique_name='a1') + el = EasyList(a1, protected_types=Alpha) + el.clear() + assert len(el) == 0 + # --- Serialization --- def test_to_dict(self): From 9d22976f8e99356fd029879160c79d5589f4dbb1 Mon Sep 17 00:00:00 2001 From: Christian Vedel Date: Fri, 20 Feb 2026 15:14:33 +0100 Subject: [PATCH 6/7] Fixes based on PR --- src/easyscience/base_classes/__init__.py | 9 +--- src/easyscience/base_classes/easy_list.py | 51 ++++++------------- .../unit_tests/base_classes/test_easy_list.py | 19 +++---- 3 files changed, 27 insertions(+), 52 deletions(-) diff --git a/src/easyscience/base_classes/__init__.py b/src/easyscience/base_classes/__init__.py index 2d13a3ba..878d869e 100644 --- a/src/easyscience/base_classes/__init__.py +++ b/src/easyscience/base_classes/__init__.py @@ -5,11 +5,4 @@ from .new_base import NewBase from .obj_base import ObjBase -__all__ = [ - BasedBase, - CollectionBase, - ObjBase, - ModelBase, - NewBase, - EasyList -] +__all__ = [BasedBase, CollectionBase, ObjBase, ModelBase, NewBase, EasyList] diff --git a/src/easyscience/base_classes/easy_list.py b/src/easyscience/base_classes/easy_list.py index 1a9a7081..0d84eb38 100644 --- a/src/easyscience/base_classes/easy_list.py +++ b/src/easyscience/base_classes/easy_list.py @@ -4,11 +4,12 @@ from __future__ import annotations +import copy import warnings from collections.abc import MutableSequence -from typing import TYPE_CHECKING from typing import Any from typing import Callable +from typing import Dict from typing import Iterable from typing import List from typing import Optional @@ -16,21 +17,16 @@ from typing import TypeVar from typing import overload -from pyparsing import Dict - from easyscience.io.serializer_base import SerializerBase from .new_base import NewBase -if TYPE_CHECKING: - pass - ProtectedType_ = TypeVar('ProtectedType', bound=NewBase) class EasyList(NewBase, MutableSequence[ProtectedType_]): - # If we were to inherit from List instead of MutableSequence, - # we would have to overwrite "extend", "remove", "__iadd__", "count" and "clear" + # If we were to inherit from List instead of MutableSequence, + # we would have to overwrite "extend", "remove", "__iadd__", "count", "append", "__iter__" and "clear" def __init__( self, *args: ProtectedType_ | list[ProtectedType_], @@ -169,7 +165,7 @@ def insert(self, index: int, value: ProtectedType_) -> None: return self._data.insert(index, value) - def _get_key(self, object) -> str: + def _get_key(self, obj) -> str: """ Get the unique name of an object. Can be overridden to use a different attribute as the key. @@ -177,32 +173,29 @@ def _get_key(self, object) -> str: :return: The key of the object :rtype: str """ - return object.unique_name + return obj.unique_name # Overwriting methods def __repr__(self) -> str: return f'{self.__class__.__name__} of length {len(self)} of type(s) {self._protected_types}' - def __iter__(self) -> Any: - return iter(self._data) - def __contains__(self, item: ProtectedType_ | str) -> bool: if isinstance(item, str): return any(self._get_key(r) == item for r in self._data) return item in self._data - + def __reversed__(self): return self._data.__reversed__() - def sort(self, mapping: Callable[[ProtectedType_], Any], reverse: bool = False) -> None: + def sort(self, key: Callable[[ProtectedType_], Any] = None, reverse: bool = False) -> None: """ - Sort the collection according to the given mapping. + Sort the collection according to the given key function. - :param mapping: Mapping function to sort by + :param key: Mapping function to sort by :param reverse: Whether to reverse the sort """ - self._data.sort(key=mapping, reverse=reverse) # type: ignore[arg-type] + self._data.sort(reverse=reverse, key=key) def index(self, value: ProtectedType_ | str, start: int = 0, stop: int = None) -> int: if stop is None: @@ -214,19 +207,6 @@ def index(self, value: ProtectedType_ | str, start: int = 0, stop: int = None) - raise ValueError(f'{value} is not in EasyList') return self._data.index(value, start, stop) - def append(self, value: ProtectedType_) -> None: - """ - Append an item to the end of the collection. - - :param value: Item to append - """ - if not isinstance(value, tuple(self._protected_types)): - raise TypeError(f'Items must be one of {self._protected_types}, got {type(value)}') - if value in self: - warnings.warn(f'Item with unique name "{self._get_key(value)}" already in EasyList, it will be ignored') - return - self._data.append(value) - def pop(self, index: int | str = -1) -> ProtectedType_: """ Remove and return an item at the given index or unique_name. @@ -270,9 +250,10 @@ def from_dict(cls, obj_dict: Dict[str, Any]) -> NewBase: """ if not SerializerBase._is_serialized_easyscience_object(obj_dict): raise ValueError('Input must be a dictionary representing an EasyScience EasyList object.') - if obj_dict['@class'] == cls.__name__: - if 'protected_types' in obj_dict: - protected_types = obj_dict.pop('protected_types') + temp_dict = copy.deepcopy(obj_dict) # Make a copy to avoid mutating the input + if temp_dict['@class'] == cls.__name__: + if 'protected_types' in temp_dict: + protected_types = temp_dict.pop('protected_types') for i, type_dict in enumerate(protected_types): if '@module' in type_dict and '@class' in type_dict: modname = type_dict['@module'] @@ -289,7 +270,7 @@ def from_dict(cls, obj_dict: Dict[str, Any]) -> NewBase: ) # noqa: E501 else: protected_types = None - kwargs = SerializerBase.deserialize_dict(obj_dict) + kwargs = SerializerBase.deserialize_dict(temp_dict) data = kwargs.pop('data', []) return cls(data, protected_types=protected_types, **kwargs) else: diff --git a/tests/unit_tests/base_classes/test_easy_list.py b/tests/unit_tests/base_classes/test_easy_list.py index eae484a6..4069b9d7 100644 --- a/tests/unit_tests/base_classes/test_easy_list.py +++ b/tests/unit_tests/base_classes/test_easy_list.py @@ -362,7 +362,7 @@ def test_sort(self): a1 = Alpha(unique_name='a') a2 = Alpha(unique_name='b') el = EasyList(a3, a1, a2, protected_types=Alpha) - el.sort(mapping=lambda x: x.unique_name) + el.sort(key=lambda x: x.unique_name) assert el[0].unique_name == 'a' assert el[1].unique_name == 'b' assert el[2].unique_name == 'c' @@ -372,20 +372,13 @@ def test_sort_reverse(self): a2 = Alpha(unique_name='b') a3 = Alpha(unique_name='c') el = EasyList(a1, a2, a3, protected_types=Alpha) - el.sort(mapping=lambda x: x.unique_name, reverse=True) + el.sort(key=lambda x: x.unique_name, reverse=True) assert el[0].unique_name == 'c' assert el[1].unique_name == 'b' assert el[2].unique_name == 'a' # --- __iter__ / __len__ --- - def test_iter(self): - a1 = Alpha(unique_name='a1') - a2 = Alpha(unique_name='a2') - el = EasyList(a1, a2, protected_types=Alpha) - names = [item.unique_name for item in el] - assert names == ['a1', 'a2'] - def test_len(self): el = EasyList(protected_types=Alpha) assert len(el) == 0 @@ -404,6 +397,13 @@ def test_repr(self): # --- MutableSequence behavior --- # If we were to inherit from List instead of MutableSequence, we would have to implement all of these manually. + def test_iter(self): + a1 = Alpha(unique_name='a1') + a2 = Alpha(unique_name='a2') + el = EasyList(a1, a2, protected_types=Alpha) + names = [item.unique_name for item in el] + assert names == ['a1', 'a2'] + def test_extend(self): a1 = Alpha(unique_name='a1') a2 = Alpha(unique_name='a2') @@ -473,3 +473,4 @@ def test_from_dict_round_trip(self): assert len(el2) == 2 assert el2[0].unique_name == 'a1' assert el2[1].unique_name == 'a2' + assert d == el2.to_dict() # The dicts should be the same after round trip From eb1cd25ab408b111d364cefee0811a3e8b5b522a Mon Sep 17 00:00:00 2001 From: Christian Vedel Date: Fri, 20 Feb 2026 16:12:37 +0100 Subject: [PATCH 7/7] Fix replacement-in place --- src/easyscience/base_classes/easy_list.py | 16 ++++--- .../unit_tests/base_classes/test_easy_list.py | 48 ++++++++++++++++++- 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/src/easyscience/base_classes/easy_list.py b/src/easyscience/base_classes/easy_list.py index 0d84eb38..6af761e6 100644 --- a/src/easyscience/base_classes/easy_list.py +++ b/src/easyscience/base_classes/easy_list.py @@ -111,20 +111,24 @@ def __setitem__(self, idx: int | slice, value: ProtectedType_ | Iterable[Protect if isinstance(idx, int): if not isinstance(value, tuple(self._protected_types)): raise TypeError(f'Items must be one of {self._protected_types}, got {type(value)}') - if value in self: + if value is not self._data[idx] and value in self: warnings.warn(f'Item with unique name "{self._get_key(value)}" already in EasyList, it will be ignored') return self._data[idx] = value elif isinstance(idx, slice): if not isinstance(value, Iterable): raise TypeError('Value must be an iterable for slice assignment') - for v in value: + replaced = self._data[idx] + new_values = list(value) + if len(new_values) != len(replaced): + raise ValueError('Length of new values must match the length of the slice being replaced') + for i, v in enumerate(new_values): if not isinstance(v, tuple(self._protected_types)): raise TypeError(f'Items must be one of {self._protected_types}, got {type(v)}') - if v in self: - warnings.warn(f'Item with unique name "{self._get_key(v)}" already in EasyList, it will be ignored') - return - self._data[idx] = list(value) # type: ignore[arg-type] + if v in self and replaced[i] is not v: + warnings.warn(f'Item with unique name "{v.unique_name}" already in EasyList, it will be ignored') + new_values[i] = replaced[i] # Keep the original value if the new one is a duplicate + self._data[idx] = new_values else: raise TypeError('Index must be an int or slice') diff --git a/tests/unit_tests/base_classes/test_easy_list.py b/tests/unit_tests/base_classes/test_easy_list.py index 4069b9d7..18e5989c 100644 --- a/tests/unit_tests/base_classes/test_easy_list.py +++ b/tests/unit_tests/base_classes/test_easy_list.py @@ -169,6 +169,48 @@ def test_setitem_slice(self): assert el[0].unique_name == 'a3' assert el[1].unique_name == 'a4' + def test_setitem_self_replacement_int(self): + """e[0] = e[0] should work without warning.""" + a1 = Alpha(unique_name='a1') + a2 = Alpha(unique_name='a2') + el = EasyList(a1, a2, protected_types=Alpha) + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter('always') + el[0] = el[0] + assert len(w) == 0 + assert el[0].unique_name == 'a1' + assert len(el) == 2 + + def test_setitem_self_replacement_slice(self): + """e[0:2] = e[0:2] should work without warning.""" + a1 = Alpha(unique_name='a1') + a2 = Alpha(unique_name='a2') + el = EasyList(a1, a2, protected_types=Alpha) + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter('always') + el[0:2] = [el[0], el[1]] + assert len(w) == 0 + assert el[0].unique_name == 'a1' + assert el[1].unique_name == 'a2' + + def test_setitem_slice_partial_self_replacement(self): + """e[0:2] = [e[0], new] should only warn about the new item.""" + a1 = Alpha(unique_name='a1') + a2 = Alpha(unique_name='a2') + a3 = Alpha(unique_name='a3') + el = EasyList(a1, a2, protected_types=Alpha) + el[0:2] = [el[0], a3] + assert el[0].unique_name == 'a1' + assert el[1].unique_name == 'a3' + + def test_setitem_slice_length_mismatch_raises(self): + a1 = Alpha(unique_name='a1') + a2 = Alpha(unique_name='a2') + a3 = Alpha(unique_name='a3') + el = EasyList(a1, a2, protected_types=Alpha) + with pytest.raises(ValueError, match='Length of new values must match the length of the slice being replaced'): + el[0:2] = [a3] # Only one item provided for a slice of length 2 + def test_setitem_invalid_index_type(self): a1 = Alpha(unique_name='a1') el = EasyList(a1, protected_types=Alpha) @@ -247,12 +289,16 @@ def test_setitem_slice_duplicate_warns(self): a1 = Alpha(unique_name='a1') a2 = Alpha(unique_name='a2') a3 = Alpha(unique_name='a3') + a4 = Alpha(unique_name='a4') el = EasyList(a1, a2, a3, protected_types=Alpha) with warnings.catch_warnings(record=True) as w: warnings.simplefilter('always') - el[0:1] = [a3] # a3 already at index 2 + el[0:3] = [a1, a3, a4] # a3 already at index 2 assert len(w) == 1 assert 'already in EasyList' in str(w[0].message) + assert el[0].unique_name == 'a1' + assert el[1].unique_name == 'a2' # a3 should not replace a2 because it's a duplicate + assert el[2].unique_name == 'a4' # --- insert ---