From 5bd111e03dabab4be331249bfbdaa1a05a40676a Mon Sep 17 00:00:00 2001 From: rozyczko Date: Tue, 2 Dec 2025 14:33:05 +0100 Subject: [PATCH 1/9] try force pushing docs to gh-pages --- .github/workflows/documentation-build.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/documentation-build.yml b/.github/workflows/documentation-build.yml index 60c73bf..b7ff9f9 100644 --- a/.github/workflows/documentation-build.yml +++ b/.github/workflows/documentation-build.yml @@ -43,4 +43,5 @@ jobs: uses: ad-m/github-push-action@master continue-on-error: true with: - branch: gh-pages \ No newline at end of file + branch: gh-pages + force: true From 0a78fae09352db793ebd50847d40936e3c70c98b Mon Sep 17 00:00:00 2001 From: rozyczko Date: Tue, 2 Dec 2025 15:11:18 +0100 Subject: [PATCH 2/9] copy the content of the produced docs, not the main repo --- .github/workflows/documentation-build.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/documentation-build.yml b/.github/workflows/documentation-build.yml index b7ff9f9..8603a01 100644 --- a/.github/workflows/documentation-build.yml +++ b/.github/workflows/documentation-build.yml @@ -39,9 +39,8 @@ jobs: # install_requirements: true # documentation_path: docs/src - - name: Push changes - uses: ad-m/github-push-action@master - continue-on-error: true + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v3 with: - branch: gh-pages - force: true + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./docs/_build/html From e7299b205fb4076d100037284bba40490866899f Mon Sep 17 00:00:00 2001 From: rozyczko Date: Tue, 2 Dec 2025 15:35:51 +0100 Subject: [PATCH 3/9] update the documentation page address --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 56e2ceb..906374a 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ After installation, launch the test suite: Documentation can be found at: -[https://easyScience.github.io/EasyScience](https://easyScience.github.io/EasyScience) +[https://easyScience.github.io/corelib](https://easyScience.github.io/corelib) ## Contributing We absolutely welcome contributions. **EasyScience** is maintained by the ESS and on a volunteer basis and thus we need to foster a community that can support user questions and develop new features to make this software a useful tool for all users while encouraging every member of the community to share their ideas. From fd9a5a4f936870008716d0cb217982d5f0393378 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Wed, 3 Dec 2025 11:11:12 +0100 Subject: [PATCH 4/9] added codecov badge --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 906374a..b41a80b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](CODE_OF_CONDUCT.md) [![PyPI badge](http://img.shields.io/pypi/v/EasyScience.svg)](https://pypi.python.org/pypi/EasyScience) [![License: BSD 3-Clause](https://img.shields.io/badge/License-BSD%203--Clause-blue.svg)](LICENSE) +[![codecov](https://codecov.io/github/EasyScience/corelib/graph/badge.svg?token=wc6Q0j0Q9t)](https://codecov.io/github/EasyScience/corelib) # Easyscience From 9ff2bcd12d58c1248d7cac8d8c69df09d0cdcdda Mon Sep 17 00:00:00 2001 From: rozyczko Date: Wed, 3 Dec 2025 14:27:03 +0100 Subject: [PATCH 5/9] apparent need to write permissions on actions-gh-pages --- .github/workflows/documentation-build.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/documentation-build.yml b/.github/workflows/documentation-build.yml index 8603a01..fd58996 100644 --- a/.github/workflows/documentation-build.yml +++ b/.github/workflows/documentation-build.yml @@ -16,6 +16,9 @@ on: # Allows you to run this workflow manually from the Actions tab workflow_dispatch: +permissions: + contents: write + # A workflow run is made up of one or more jobs that can run sequentially or in parallel jobs: # This workflow contains a single job called "build" From 08ef4e4ca1c3e742fbb96fc109ee021b4a6a0cca Mon Sep 17 00:00:00 2001 From: Christian Dam Vedel <158568093+damskii9992@users.noreply.github.com> Date: Wed, 17 Dec 2025 11:42:28 +0100 Subject: [PATCH 6/9] Add labelSeparator option to issue label checks --- .github/workflows/verify_issue_labels.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/verify_issue_labels.yml b/.github/workflows/verify_issue_labels.yml index 69ec3a1..4315fe7 100644 --- a/.github/workflows/verify_issue_labels.yml +++ b/.github/workflows/verify_issue_labels.yml @@ -17,6 +17,7 @@ jobs: with: secret: ${{ github.TOKEN }} prefix: "[scope]" + labelSeparator: " " addLabel: true defaultLabel: "[scope] ⚠️ label needed" @@ -26,6 +27,7 @@ jobs: with: secret: ${{ github.TOKEN }} prefix: "[priority]" + labelSeparator: " " addLabel: true defaultLabel: "[priority] ⚠️ label needed" From 4f569ce8027d664bf088f7c5505e5d36fc580585 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Fri, 19 Dec 2025 09:43:52 +0100 Subject: [PATCH 7/9] Add ModelCollection class for NewBase/ModelBase objects - Add ModelCollection class extending ModelBase with MutableSequence - Provides list-like functionality with EasyScience features - Supports interface propagation to collection items (via InterfaceFactoryTemplate) - Add NewBase to CollectionBase type checking - Export ModelCollection from base_classes module - Add comprehensive unit tests (102 tests) --- src/easyscience/base_classes/__init__.py | 2 + .../base_classes/collection_base.py | 3 +- .../base_classes/model_collection.py | 281 ++++++++ .../base_classes/test_model_collection.py | 675 ++++++++++++++++++ 4 files changed, 960 insertions(+), 1 deletion(-) create mode 100644 src/easyscience/base_classes/model_collection.py create mode 100644 tests/unit_tests/base_classes/test_model_collection.py diff --git a/src/easyscience/base_classes/__init__.py b/src/easyscience/base_classes/__init__.py index 9f3ba08..9a8dc2d 100644 --- a/src/easyscience/base_classes/__init__.py +++ b/src/easyscience/base_classes/__init__.py @@ -1,6 +1,7 @@ from .based_base import BasedBase from .collection_base import CollectionBase from .model_base import ModelBase +from .model_collection import ModelCollection from .new_base import NewBase from .obj_base import ObjBase @@ -9,5 +10,6 @@ CollectionBase, ObjBase, ModelBase, + ModelCollection, NewBase, ] diff --git a/src/easyscience/base_classes/collection_base.py b/src/easyscience/base_classes/collection_base.py index 3cc0586..e08ae26 100644 --- a/src/easyscience/base_classes/collection_base.py +++ b/src/easyscience/base_classes/collection_base.py @@ -18,6 +18,7 @@ from ..variable.descriptor_base import DescriptorBase from .based_base import BasedBase +from .new_base import NewBase if TYPE_CHECKING: from ..fitting.calculators import InterfaceFactoryTemplate @@ -64,7 +65,7 @@ def __init__( _kwargs[key] = item kwargs = _kwargs for item in list(kwargs.values()) + _args: - if not issubclass(type(item), (DescriptorBase, BasedBase)): + if not issubclass(type(item), (DescriptorBase, BasedBase, NewBase)): raise AttributeError('A collection can only be formed from easyscience objects.') args = _args _kwargs = {} diff --git a/src/easyscience/base_classes/model_collection.py b/src/easyscience/base_classes/model_collection.py new file mode 100644 index 0000000..fea2cd7 --- /dev/null +++ b/src/easyscience/base_classes/model_collection.py @@ -0,0 +1,281 @@ +# SPDX-FileCopyrightText: 2025 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +# © 2021-2025 Contributors to the EasyScience project None: + """Add an item to the collection and set up graph edges. + + Note: Duplicate items (same object reference) are silently ignored. + """ + if not isinstance(item, NewBase): + raise TypeError(f'Items must be NewBase objects, got {type(item)}') + if item in self._data: + return # Skip duplicates to avoid multiple graph edges + self._data.append(item) + self._global_object.map.add_edge(self, item) + self._global_object.map.reset_type(item, 'created_internal') + if self._interface is not None and hasattr(item, 'interface'): + setattr(item, 'interface', self._interface) + + def _remove_item(self, item: NewBase) -> None: + """Remove an item from the collection and clean up graph edges.""" + self._global_object.map.prune_vertex_from_edge(self, item) + + @property + def interface(self) -> InterfaceType: + """Get the current interface of the collection.""" + return self._interface + + @interface.setter + def interface(self, new_interface: InterfaceType) -> None: + """Set the interface and propagate to all items. + + :param new_interface: The interface to set (must be InterfaceFactoryTemplate or None) + :raises TypeError: If the interface is not a valid type + """ + # Import here to avoid circular imports + from ..fitting.calculators import InterfaceFactoryTemplate + + if new_interface is not None and not isinstance(new_interface, InterfaceFactoryTemplate): + raise TypeError( + f'interface must be InterfaceFactoryTemplate or None, ' + f'got {type(new_interface).__name__}' + ) + + self._interface = new_interface + for item in self._data: + if hasattr(item, 'interface'): + setattr(item, 'interface', new_interface) + + # MutableSequence abstract methods + + # Use @overload to provide precise type hints for different __getitem__ argument types + @overload + def __getitem__(self, idx: int) -> T: ... + @overload + def __getitem__(self, idx: slice) -> 'ModelCollection[T]': ... + @overload + def __getitem__(self, idx: str) -> T: ... + + def __getitem__(self, idx: int | slice | str) -> T | 'ModelCollection[T]': + """ + Get an item by index, slice, or name. + + :param idx: Index, slice, or name of the item + :return: The item or a new collection for slices + """ + if isinstance(idx, slice): + start, stop, step = idx.indices(len(self)) + return self.__class__(*[self._data[i] for i in range(start, stop, step)]) + if isinstance(idx, str): + # Search by name + for item in self._data: + if hasattr(item, 'name') and getattr(item, 'name') == idx: + return item # type: ignore[return-value] + if hasattr(item, 'unique_name') and item.unique_name == idx: + return item # type: ignore[return-value] + raise KeyError(f'No item with name "{idx}" found') + return self._data[idx] # type: ignore[return-value] + + @overload + def __setitem__(self, idx: int, value: T) -> None: ... + @overload + def __setitem__(self, idx: slice, value: Iterable[T]) -> None: ... + + def __setitem__(self, idx: int | slice, value: T | Iterable[T]) -> None: + """ + Set an item at an index. + + :param idx: Index to set + :param value: New value + """ + if isinstance(idx, slice): + # Handle slice assignment + values = list(value) # type: ignore[arg-type] + # Remove old items + start, stop, step = idx.indices(len(self)) + for i in range(start, stop, step): + self._remove_item(self._data[i]) + # Set new items + self._data[idx] = values # type: ignore[assignment] + for v in values: + self._global_object.map.add_edge(self, v) + self._global_object.map.reset_type(v, 'created_internal') + if self._interface is not None and hasattr(v, 'interface'): + setattr(v, 'interface', self._interface) + else: + if not isinstance(value, NewBase): + raise TypeError(f'Items must be NewBase objects, got {type(value)}') + + old_item = self._data[idx] + self._remove_item(old_item) + + self._data[idx] = value # type: ignore[assignment] + self._global_object.map.add_edge(self, value) + self._global_object.map.reset_type(value, 'created_internal') + if self._interface is not None and hasattr(value, 'interface'): + setattr(value, 'interface', self._interface) + + @overload + def __delitem__(self, idx: int) -> None: ... + @overload + def __delitem__(self, idx: slice) -> None: ... + @overload + def __delitem__(self, idx: str) -> None: ... + + 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, slice): + start, stop, step = idx.indices(len(self)) + indices = list(range(start, stop, step)) + # Remove in reverse order to maintain indices + for i in reversed(indices): + item = self._data[i] + self._remove_item(item) + del self._data[i] + elif isinstance(idx, str): + for i, item in enumerate(self._data): + if hasattr(item, 'name') and getattr(item, 'name') == idx: + idx = i + break + if hasattr(item, 'unique_name') and item.unique_name == idx: + idx = i + break + else: + raise KeyError(f'No item with name "{idx}" found') + + item = self._data[idx] + self._remove_item(item) + del self._data[idx] + else: + item = self._data[idx] + self._remove_item(item) + del self._data[idx] + + def __len__(self) -> int: + """Return the number of items in the collection.""" + return len(self._data) + + def insert(self, index: int, value: T) -> None: + """ + Insert an item at an index. + + :param index: Index to insert at + :param value: Item to insert + """ + if not isinstance(value, NewBase): + raise TypeError(f'Items must be NewBase objects, got {type(value)}') + + self._data.insert(index, value) # type: ignore[arg-type] + self._global_object.map.add_edge(self, value) + self._global_object.map.reset_type(value, 'created_internal') + if self._interface is not None and hasattr(value, 'interface'): + setattr(value, 'interface', self._interface) + + # Additional utility methods + + @property + def data(self) -> tuple: + """Return the data as a tuple.""" + return tuple(self._data) + + def sort(self, mapping: Callable[[T], 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)}' + + def __iter__(self) -> Any: + return iter(self._data) + + # Serialization support + + def _convert_to_dict(self, in_dict: dict, encoder: Any, skip: Optional[List[str]] = None, **kwargs: Any) -> dict: + """Convert the collection to a dictionary for serialization.""" + if skip is None: + skip = [] + d: dict = {} + if hasattr(self, '_modify_dict'): + d = self._modify_dict(skip=skip, **kwargs) # type: ignore[attr-defined] + in_dict['data'] = [encoder._convert_to_dict(item, skip=skip, **kwargs) for item in self._data] + return {**in_dict, **d} + + def get_all_variables(self) -> List[Any]: + """Get all variables from all items in the collection.""" + variables: List[Any] = [] + for item in self._data: + if hasattr(item, 'get_all_variables'): + variables.extend(item.get_all_variables()) # type: ignore[attr-defined] + return variables diff --git a/tests/unit_tests/base_classes/test_model_collection.py b/tests/unit_tests/base_classes/test_model_collection.py new file mode 100644 index 0000000..577bb77 --- /dev/null +++ b/tests/unit_tests/base_classes/test_model_collection.py @@ -0,0 +1,675 @@ +# SPDX-FileCopyrightText: 2025 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +# © 2021-2025 Contributors to the EasyScience project str: + return self._name + + @property + def value(self) -> Parameter: + return self._value + + @value.setter + def value(self, new_value: float) -> None: + self._value.value = new_value + + +class DerivedModelCollection(ModelCollection): + """A derived class for testing inheritance.""" + pass + + +class_constructors = [ModelCollection, DerivedModelCollection] + + +@pytest.fixture +def clear_global(): + """Clear the global object map before each test.""" + global_object.map._clear() + yield + global_object.map._clear() + + +@pytest.fixture +def sample_items(): + """Create sample items for testing.""" + return [ + MockModelItem(name='item1', value=1.0), + MockModelItem(name='item2', value=2.0), + MockModelItem(name='item3', value=3.0), + ] + + +# ============================================================================= +# Constructor Tests +# ============================================================================= + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_init_empty(cls, clear_global): + """Test creating an empty collection.""" + coll = cls() + assert len(coll) == 0 + assert coll.interface is None + + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_init_with_items(cls, clear_global, sample_items): + """Test creating a collection with initial items.""" + coll = cls(*sample_items) + assert len(coll) == 3 + for i, item in enumerate(coll): + assert item.name == sample_items[i].name + + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_init_with_unique_name(cls, clear_global): + """Test creating a collection with a custom unique_name.""" + coll = cls(unique_name='custom_unique') + assert coll.unique_name == 'custom_unique' + + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_init_with_display_name(cls, clear_global): + """Test creating a collection with a custom display_name.""" + coll = cls(display_name='My Display Name') + assert coll.display_name == 'My Display Name' + + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_init_with_list_arg(cls, clear_global, sample_items): + """Test creating a collection with a list of items (should flatten).""" + coll = cls(sample_items) + assert len(coll) == 3 + + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_init_type_error(cls, clear_global): + """Test that adding non-NewBase items raises TypeError.""" + with pytest.raises(TypeError): + cls('not_a_newbase_object') + + +# ============================================================================= +# Interface Property Tests +# ============================================================================= + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_interface_default(cls, clear_global): + """Test that interface defaults to None.""" + coll = cls() + assert coll.interface is None + + +@pytest.mark.parametrize('cls', class_constructors) +@pytest.mark.filterwarnings('ignore::DeprecationWarning') +def test_ModelCollection_interface_propagation(cls, clear_global, sample_items): + """Test that setting interface propagates to items.""" + # Add interface attribute to items for this test + for item in sample_items: + item.interface = None + + coll = cls(*sample_items) + + # Create a mock interface that inherits from InterfaceFactoryTemplate + class MockInterfaceClass: + name = "MockInterface" + def __init__(self, *args, **kwargs): + pass + def fit_func(self, *args, **kwargs): + return "result" + def create(self, model): + return [] + + mock_interface = InterfaceFactoryTemplate([MockInterfaceClass]) + coll.interface = mock_interface + + assert coll.interface is mock_interface + for item in coll: + assert item.interface is mock_interface + + +@pytest.mark.parametrize('cls', class_constructors) +@pytest.mark.filterwarnings('ignore::DeprecationWarning') +def test_ModelCollection_interface_type_error(cls, clear_global): + """Test that setting an invalid interface type raises TypeError.""" + coll = cls() + + with pytest.raises(TypeError, match='interface must be'): + coll.interface = 'not_an_interface' + + +@pytest.mark.parametrize('cls', class_constructors) +@pytest.mark.filterwarnings('ignore::DeprecationWarning') +def test_ModelCollection_interface_type_error_with_object(cls, clear_global): + """Test that setting a plain object as interface raises TypeError.""" + coll = cls() + + class NotAnInterface: + pass + + with pytest.raises(TypeError, match='interface must be'): + coll.interface = NotAnInterface() + + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_interface_accepts_none(cls, clear_global, sample_items): + """Test that setting interface to None is allowed.""" + coll = cls(*sample_items) + coll.interface = None + assert coll.interface is None + + +# ============================================================================= +# __getitem__ Tests +# ============================================================================= + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_getitem_int(cls, clear_global, sample_items): + """Test getting items by integer index.""" + coll = cls(*sample_items) + assert coll[0].name == 'item1' + assert coll[1].name == 'item2' + assert coll[2].name == 'item3' + assert coll[-1].name == 'item3' + + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_getitem_int_out_of_range(cls, clear_global, sample_items): + """Test that out of range index raises IndexError.""" + coll = cls(*sample_items) + with pytest.raises(IndexError): + _ = coll[100] + + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_getitem_slice(cls, clear_global, sample_items): + """Test getting items by slice.""" + coll = cls(*sample_items) + sliced = coll[0:2] + assert isinstance(sliced, cls) + assert len(sliced) == 2 + assert sliced[0].name == 'item1' + assert sliced[1].name == 'item2' + + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_getitem_str_by_name(cls, clear_global, sample_items): + """Test getting items by name string.""" + coll = cls(*sample_items) + item = coll['item2'] + assert item.name == 'item2' + + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_getitem_str_by_unique_name(cls, clear_global, sample_items): + """Test getting items by unique_name string.""" + coll = cls(*sample_items) + unique_name = sample_items[1].unique_name + item = coll[unique_name] + assert item.unique_name == unique_name + + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_getitem_str_not_found(cls, clear_global, sample_items): + """Test that getting non-existent name raises KeyError.""" + coll = cls(*sample_items) + with pytest.raises(KeyError): + _ = coll['nonexistent'] + + +# ============================================================================= +# __setitem__ Tests +# ============================================================================= + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_setitem_int(cls, clear_global, sample_items): + """Test setting items by integer index.""" + coll = cls(*sample_items) + new_item = MockModelItem(name='new_item', value=99.0) + old_item = coll[1] + + coll[1] = new_item + + assert len(coll) == 3 + assert coll[1].name == 'new_item' + assert coll[1].value.value == 99.0 + + # Check graph edges + edges = global_object.map.get_edges(coll) + assert new_item.unique_name in edges + assert old_item.unique_name not in edges + + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_setitem_type_error(cls, clear_global, sample_items): + """Test that setting non-NewBase item raises TypeError.""" + coll = cls(*sample_items) + with pytest.raises(TypeError): + coll[0] = 'not_a_newbase_object' + + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_setitem_slice(cls, clear_global, sample_items): + """Test setting items by slice.""" + coll = cls(*sample_items) + new_items = [ + MockModelItem(name='new1', value=10.0), + MockModelItem(name='new2', value=20.0), + ] + + coll[0:2] = new_items + + assert len(coll) == 3 + assert coll[0].name == 'new1' + assert coll[1].name == 'new2' + assert coll[2].name == 'item3' + + +# ============================================================================= +# __delitem__ Tests +# ============================================================================= + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_delitem_int(cls, clear_global, sample_items): + """Test deleting items by integer index.""" + coll = cls(*sample_items) + deleted_item = coll[1] + + del coll[1] + + assert len(coll) == 2 + assert coll[0].name == 'item1' + assert coll[1].name == 'item3' + + # Check graph edges + edges = global_object.map.get_edges(coll) + assert deleted_item.unique_name not in edges + + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_delitem_slice(cls, clear_global, sample_items): + """Test deleting items by slice.""" + coll = cls(*sample_items) + + del coll[0:2] + + assert len(coll) == 1 + assert coll[0].name == 'item3' + + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_delitem_str_by_name(cls, clear_global, sample_items): + """Test deleting items by name string.""" + coll = cls(*sample_items) + + del coll['item2'] + + assert len(coll) == 2 + assert 'item2' not in [item.name for item in coll] + + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_delitem_str_not_found(cls, clear_global, sample_items): + """Test that deleting non-existent name raises KeyError.""" + coll = cls(*sample_items) + with pytest.raises(KeyError): + del coll['nonexistent'] + + +# ============================================================================= +# __len__ Tests +# ============================================================================= + +@pytest.mark.parametrize('cls', class_constructors) +@pytest.mark.parametrize('count', [0, 1, 3, 5]) +def test_ModelCollection_len(cls, clear_global, count): + """Test __len__ returns correct count.""" + items = [MockModelItem(name=f'item{i}', value=float(i)) for i in range(count)] + coll = cls(*items) + assert len(coll) == count + + +# ============================================================================= +# insert Tests +# ============================================================================= + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_insert(cls, clear_global, sample_items): + """Test inserting items at an index.""" + coll = cls(*sample_items) + new_item = MockModelItem(name='inserted', value=99.0) + + coll.insert(1, new_item) + + assert len(coll) == 4 + assert coll[0].name == 'item1' + assert coll[1].name == 'inserted' + assert coll[2].name == 'item2' + assert coll[3].name == 'item3' + + # Check graph edges + edges = global_object.map.get_edges(coll) + assert new_item.unique_name in edges + + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_insert_type_error(cls, clear_global, sample_items): + """Test that inserting non-NewBase item raises TypeError.""" + coll = cls(*sample_items) + with pytest.raises(TypeError): + coll.insert(0, 'not_a_newbase_object') + + +# ============================================================================= +# append Tests (inherited from MutableSequence) +# ============================================================================= + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_append(cls, clear_global, sample_items): + """Test appending items.""" + coll = cls(*sample_items) + new_item = MockModelItem(name='appended', value=99.0) + + coll.append(new_item) + + assert len(coll) == 4 + assert coll[-1].name == 'appended' + + # Check graph edges + edges = global_object.map.get_edges(coll) + assert new_item.unique_name in edges + + +# ============================================================================= +# data Property Tests +# ============================================================================= + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_data_property(cls, clear_global, sample_items): + """Test that data property returns tuple of items.""" + coll = cls(*sample_items) + data = coll.data + assert isinstance(data, tuple) + assert len(data) == 3 + for i, item in enumerate(data): + assert item.name == sample_items[i].name + + +# ============================================================================= +# sort Tests +# ============================================================================= + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_sort(cls, clear_global): + """Test sorting the collection.""" + items = [ + MockModelItem(name='c', value=3.0), + MockModelItem(name='a', value=1.0), + MockModelItem(name='b', value=2.0), + ] + coll = cls(*items) + + coll.sort(lambda x: x.value.value) + + assert coll[0].name == 'a' + assert coll[1].name == 'b' + assert coll[2].name == 'c' + + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_sort_reverse(cls, clear_global): + """Test sorting the collection in reverse.""" + items = [ + MockModelItem(name='a', value=1.0), + MockModelItem(name='c', value=3.0), + MockModelItem(name='b', value=2.0), + ] + coll = cls(*items) + + coll.sort(lambda x: x.value.value, reverse=True) + + assert coll[0].name == 'c' + assert coll[1].name == 'b' + assert coll[2].name == 'a' + + +# ============================================================================= +# __repr__ Tests +# ============================================================================= + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_repr(cls, clear_global, sample_items): + """Test string representation.""" + coll = cls(*sample_items) + repr_str = repr(coll) + assert cls.__name__ in repr_str + assert '3' in repr_str + + +# ============================================================================= +# __iter__ Tests +# ============================================================================= + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_iter(cls, clear_global, sample_items): + """Test iteration over collection.""" + coll = cls(*sample_items) + + names = [item.name for item in coll] + assert names == ['item1', 'item2', 'item3'] + + +# ============================================================================= +# get_all_variables Tests +# ============================================================================= + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_get_all_variables(cls, clear_global, sample_items): + """Test getting all variables from items.""" + coll = cls(*sample_items) + variables = coll.get_all_variables() + + # Each MockModelItem has one Parameter (value) + assert len(variables) == 3 + for var in variables: + assert isinstance(var, Parameter) + + +# ============================================================================= +# get_all_parameters Tests +# ============================================================================= + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_get_all_parameters(cls, clear_global, sample_items): + """Test getting all parameters from items.""" + coll = cls(*sample_items) + parameters = coll.get_all_parameters() + + assert len(parameters) == 3 + for param in parameters: + assert isinstance(param, Parameter) + + +# ============================================================================= +# get_fit_parameters Tests +# ============================================================================= + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_get_fit_parameters(cls, clear_global, sample_items): + """Test getting fit parameters from items.""" + # Fix one parameter so we can test filtering + sample_items[0].value.fixed = True + + coll = cls(*sample_items) + fit_params = coll.get_fit_parameters() + + # All 3 parameters should be returned (get_fit_parameters on items) + # since MockModelItem.get_fit_parameters returns free params + assert len(fit_params) == 2 + + +# ============================================================================= +# Graph Edge Tests +# ============================================================================= + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_graph_edges(cls, clear_global, sample_items): + """Test that graph edges are correctly maintained.""" + coll = cls(*sample_items) + + edges = global_object.map.get_edges(coll) + assert len(edges) == 3 + + for item in sample_items: + assert item.unique_name in edges + + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_graph_edges_after_append(cls, clear_global, sample_items): + """Test graph edges are updated after append.""" + coll = cls(*sample_items) + new_item = MockModelItem(name='new', value=99.0) + + coll.append(new_item) + + edges = global_object.map.get_edges(coll) + assert len(edges) == 4 + assert new_item.unique_name in edges + + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_graph_edges_after_delete(cls, clear_global, sample_items): + """Test graph edges are updated after delete.""" + coll = cls(*sample_items) + deleted_item = sample_items[1] + + del coll[1] + + edges = global_object.map.get_edges(coll) + assert len(edges) == 2 + assert deleted_item.unique_name not in edges + + +# ============================================================================= +# MutableSequence Interface Tests +# ============================================================================= + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_extend(cls, clear_global, sample_items): + """Test extend method (inherited from MutableSequence).""" + coll = cls(sample_items[0]) + coll.extend([sample_items[1], sample_items[2]]) + + assert len(coll) == 3 + assert coll[1].name == 'item2' + assert coll[2].name == 'item3' + + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_pop(cls, clear_global, sample_items): + """Test pop method (inherited from MutableSequence).""" + coll = cls(*sample_items) + + popped = coll.pop() + assert popped.name == 'item3' + assert len(coll) == 2 + + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_pop_index(cls, clear_global, sample_items): + """Test pop method with index (inherited from MutableSequence).""" + coll = cls(*sample_items) + + popped = coll.pop(0) + assert popped.name == 'item1' + assert len(coll) == 2 + assert coll[0].name == 'item2' + + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_remove(cls, clear_global, sample_items): + """Test remove method (inherited from MutableSequence).""" + coll = cls(*sample_items) + item_to_remove = sample_items[1] + + coll.remove(item_to_remove) + + assert len(coll) == 2 + assert item_to_remove not in coll + + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_clear(cls, clear_global, sample_items): + """Test clear method (inherited from MutableSequence).""" + coll = cls(*sample_items) + + coll.clear() + + assert len(coll) == 0 + + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_reverse(cls, clear_global, sample_items): + """Test reverse method (inherited from MutableSequence).""" + coll = cls(*sample_items) + + coll.reverse() + + assert coll[0].name == 'item3' + assert coll[1].name == 'item2' + assert coll[2].name == 'item1' + + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_count(cls, clear_global, sample_items): + """Test count method (inherited from MutableSequence).""" + coll = cls(*sample_items) + + count = coll.count(sample_items[0]) + assert count == 1 + + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_index(cls, clear_global, sample_items): + """Test index method (inherited from MutableSequence).""" + coll = cls(*sample_items) + + idx = coll.index(sample_items[1]) + assert idx == 1 + + +# ============================================================================= +# Contains Tests +# ============================================================================= + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_contains(cls, clear_global, sample_items): + """Test __contains__ (in operator).""" + coll = cls(*sample_items) + + assert sample_items[0] in coll + assert sample_items[1] in coll + + new_item = MockModelItem(name='not_in_collection', value=999.0) + assert new_item not in coll From 4b253f7292caaaa57d7155d613fff0481dafd7e2 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Fri, 19 Dec 2025 11:14:05 +0100 Subject: [PATCH 8/9] ruff --- src/easyscience/base_classes/model_collection.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/easyscience/base_classes/model_collection.py b/src/easyscience/base_classes/model_collection.py index fea2cd7..3ee8930 100644 --- a/src/easyscience/base_classes/model_collection.py +++ b/src/easyscience/base_classes/model_collection.py @@ -18,7 +18,7 @@ from .new_base import NewBase if TYPE_CHECKING: - from ..fitting.calculators import InterfaceFactoryTemplate + pass # Type alias for interface InterfaceType = 'InterfaceFactoryTemplate | None' @@ -99,10 +99,7 @@ def interface(self, new_interface: InterfaceType) -> None: from ..fitting.calculators import InterfaceFactoryTemplate if new_interface is not None and not isinstance(new_interface, InterfaceFactoryTemplate): - raise TypeError( - f'interface must be InterfaceFactoryTemplate or None, ' - f'got {type(new_interface).__name__}' - ) + raise TypeError(f'interface must be InterfaceFactoryTemplate or None, got {type(new_interface).__name__}') self._interface = new_interface for item in self._data: From 4a9c99da48acd7b62f79848ee9c9506d4662cd0d Mon Sep 17 00:00:00 2001 From: rozyczko Date: Sat, 20 Dec 2025 09:31:49 +0100 Subject: [PATCH 9/9] more tests for model collection --- .../base_classes/test_model_collection.py | 241 ++++++++++++++++++ 1 file changed, 241 insertions(+) diff --git a/tests/unit_tests/base_classes/test_model_collection.py b/tests/unit_tests/base_classes/test_model_collection.py index 577bb77..417c621 100644 --- a/tests/unit_tests/base_classes/test_model_collection.py +++ b/tests/unit_tests/base_classes/test_model_collection.py @@ -673,3 +673,244 @@ def test_ModelCollection_contains(cls, clear_global, sample_items): new_item = MockModelItem(name='not_in_collection', value=999.0) assert new_item not in coll + +@pytest.mark.parametrize('cls', class_constructors) +@pytest.mark.filterwarnings('ignore::DeprecationWarning') +def test_ModelCollection_init_with_interface_propagates_to_items(cls, clear_global, sample_items): + """Test that interface passed to __init__ propagates to items.""" + for item in sample_items: + item.interface = None + + class MockInterfaceClass: + name = "MockInterface" + def __init__(self, *args, **kwargs): + pass + def fit_func(self, *args, **kwargs): + return "result" + def create(self, model): + return [] + + mock_interface = InterfaceFactoryTemplate([MockInterfaceClass]) + coll = cls(*sample_items, interface=mock_interface) + + assert coll.interface is mock_interface + for item in coll: + assert item.interface is mock_interface + + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_duplicate_items_silently_ignored(cls, clear_global): + """Test that adding the same object twice only stores one copy.""" + item = MockModelItem(name='dupe', value=1.0) + + coll = cls(item, item) + + assert len(coll) == 1 + edges = global_object.map.get_edges(coll) + assert edges == [item.unique_name] + + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_delitem_str_by_unique_name(cls, clear_global, sample_items): + """Test deleting items by unique_name string.""" + coll = cls(*sample_items) + unique_name = sample_items[1].unique_name + + del coll[unique_name] + + assert len(coll) == 2 + assert sample_items[1] not in coll + + +@pytest.mark.parametrize('cls', class_constructors) +@pytest.mark.filterwarnings('ignore::DeprecationWarning') +def test_ModelCollection_setitem_int_with_interface_propagation(cls, clear_global, sample_items): + """Test that setitem with int propagates interface to new item.""" + for item in sample_items: + item.interface = None + + class MockInterfaceClass: + name = "MockInterface" + def __init__(self, *args, **kwargs): + pass + def fit_func(self, *args, **kwargs): + return "result" + def create(self, model): + return [] + + mock_interface = InterfaceFactoryTemplate([MockInterfaceClass]) + coll = cls(*sample_items) + coll.interface = mock_interface + + new_item = MockModelItem(name='new_item', value=99.0) + new_item.interface = None + + coll[1] = new_item + + assert new_item.interface is mock_interface + + +@pytest.mark.parametrize('cls', class_constructors) +@pytest.mark.filterwarnings('ignore::DeprecationWarning') +def test_ModelCollection_setitem_slice_with_interface_propagation(cls, clear_global, sample_items): + """Test that setitem with slice propagates interface to new items.""" + for item in sample_items: + item.interface = None + + class MockInterfaceClass: + name = "MockInterface" + def __init__(self, *args, **kwargs): + pass + def fit_func(self, *args, **kwargs): + return "result" + def create(self, model): + return [] + + mock_interface = InterfaceFactoryTemplate([MockInterfaceClass]) + coll = cls(*sample_items) + coll.interface = mock_interface + + new_items = [MockModelItem(name='new1', value=10.0), MockModelItem(name='new2', value=20.0)] + for item in new_items: + item.interface = None + + coll[0:2] = new_items + + for item in new_items: + assert item.interface is mock_interface + + +@pytest.mark.parametrize('cls', class_constructors) +@pytest.mark.filterwarnings('ignore::DeprecationWarning') +def test_ModelCollection_insert_with_interface_propagation(cls, clear_global, sample_items): + """Test that insert propagates interface to new item.""" + for item in sample_items: + item.interface = None + + class MockInterfaceClass: + name = "MockInterface" + def __init__(self, *args, **kwargs): + pass + def fit_func(self, *args, **kwargs): + return "result" + def create(self, model): + return [] + + mock_interface = InterfaceFactoryTemplate([MockInterfaceClass]) + coll = cls(*sample_items) + coll.interface = mock_interface + + new_item = MockModelItem(name='inserted', value=99.0) + new_item.interface = None + + coll.insert(1, new_item) + + assert new_item.interface is mock_interface + + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_convert_to_dict_with_modify_dict(cls, clear_global, sample_items): + """Test _convert_to_dict calls _modify_dict when present.""" + class DerivedWithModifyDict(cls): + def _modify_dict(self, skip=None, **kwargs): + return {'extra_key': 'extra_value'} + + coll = DerivedWithModifyDict(*sample_items) + + class DummyEncoder: + def _convert_to_dict(self, item, skip=None, **kwargs): + return {'name': getattr(item, 'name', 'unknown')} + + encoder = DummyEncoder() + result = coll._convert_to_dict({}, encoder) + + assert result['extra_key'] == 'extra_value' + assert 'data' in result + + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_convert_to_dict_skip_none(cls, clear_global, sample_items): + """Test _convert_to_dict handles skip=None correctly.""" + coll = cls(*sample_items) + + class DummyEncoder: + def _convert_to_dict(self, item, skip=None, **kwargs): + return {'name': getattr(item, 'name', 'unknown'), 'skip': skip} + + encoder = DummyEncoder() + result = coll._convert_to_dict({}, encoder, skip=None) + + assert 'data' in result + # skip should default to [] when None is passed + for item_dict in result['data']: + assert item_dict['skip'] == [] + + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_get_all_variables_item_without_method(cls, clear_global): + """Test get_all_variables skips items without get_all_variables method.""" + # Create a minimal NewBase subclass without get_all_variables + class MinimalItem(NewBase): + def __init__(self, name): + super().__init__() + self._name = name + + @property + def name(self): + return self._name + + item_with = MockModelItem(name='with_vars', value=1.0) + item_without = MinimalItem(name='no_vars') + + coll = cls(item_with, item_without) + variables = coll.get_all_variables() + + # Only item_with has variables + assert len(variables) == 1 + + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_getitem_str_item_without_name_attr(cls, clear_global): + """Test __getitem__ with string searches by unique_name when item lacks name attr.""" + # Create item without name property + class ItemWithoutName(NewBase): + def __init__(self): + super().__init__() + + item = ItemWithoutName() + coll = cls(item) + + # Should find by unique_name + found = coll[item.unique_name] + assert found is item + + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_delitem_slice_edges_updated(cls, clear_global, sample_items): + """Test that deleting by slice updates graph edges correctly.""" + coll = cls(*sample_items) + deleted_items = [sample_items[0], sample_items[2]] + + del coll[::2] # Delete items at indices 0 and 2 + + assert len(coll) == 1 + edges = global_object.map.get_edges(coll) + for item in deleted_items: + assert item.unique_name not in edges + assert sample_items[1].unique_name in edges + + +@pytest.mark.parametrize('cls', class_constructors) +def test_ModelCollection_setitem_slice_edges_updated(cls, clear_global, sample_items): + """Test that setitem with slice updates graph edges correctly.""" + coll = cls(*sample_items) + old_items = [sample_items[0], sample_items[1]] + + new_items = [MockModelItem(name='new1', value=10.0), MockModelItem(name='new2', value=20.0)] + coll[0:2] = new_items + + edges = global_object.map.get_edges(coll) + for item in old_items: + assert item.unique_name not in edges + for item in new_items: + assert item.unique_name in edges