Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
3a3738f
Merge pull request #76 from EasyScience/develop
andped10 Sep 18, 2024
59c4a90
Merge pull request #83 from EasyScience/develop
AndrewSazonov Oct 29, 2024
23f4ced
Merge pull request #86 from EasyScience/develop
AndrewSazonov Nov 12, 2024
a6d9379
Merge pull request #87 from EasyScience/develop
andped10 Nov 13, 2024
5fdd558
Merge pull request #90 from EasyScience/develop
AndrewSazonov Nov 21, 2024
c7e2ec4
Merge pull request #106 from easyscience/develop
rozyczko Mar 13, 2025
acb7f17
Merge pull request #107 from easyscience/develop
rozyczko Mar 13, 2025
a02aa89
Merge pull request #126 from easyscience/develop
rozyczko Sep 19, 2025
5f25fc7
Merge pull request #170 from easyscience/develop
damskii9992 Dec 2, 2025
5bd111e
try force pushing docs to gh-pages
rozyczko Dec 2, 2025
0a78fae
copy the content of the produced docs, not the main repo
rozyczko Dec 2, 2025
e7299b2
update the documentation page address
rozyczko Dec 2, 2025
fd9a5a4
added codecov badge
rozyczko Dec 3, 2025
9ff2bcd
apparent need to write permissions on actions-gh-pages
rozyczko Dec 3, 2025
0fd4c37
Merge pull request #171 from easyscience/hotfix_2.1.0a
rozyczko Dec 3, 2025
08ef4e4
Add labelSeparator option to issue label checks
damskii9992 Dec 17, 2025
af7f27b
Merge pull request #178 from easyscience/Hotfix_2.1.0b
damskii9992 Dec 17, 2025
4f569ce
Add ModelCollection class for NewBase/ModelBase objects
rozyczko Dec 19, 2025
4b253f7
ruff
rozyczko Dec 19, 2025
4a9c99d
more tests for model collection
rozyczko Dec 20, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions .github/workflows/documentation-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -39,8 +42,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
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./docs/_build/html
2 changes: 2 additions & 0 deletions .github/workflows/verify_issue_labels.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ jobs:
with:
secret: ${{ github.TOKEN }}
prefix: "[scope]"
labelSeparator: " "
addLabel: true
defaultLabel: "[scope] ⚠️ label needed"

Expand All @@ -26,6 +27,7 @@ jobs:
with:
secret: ${{ github.TOKEN }}
prefix: "[priority]"
labelSeparator: " "
addLabel: true
defaultLabel: "[priority] ⚠️ label needed"

3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -33,7 +34,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.
Expand Down
2 changes: 2 additions & 0 deletions src/easyscience/base_classes/__init__.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -9,5 +10,6 @@
CollectionBase,
ObjBase,
ModelBase,
ModelCollection,
NewBase,
]
3 changes: 2 additions & 1 deletion src/easyscience/base_classes/collection_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = {}
Expand Down
278 changes: 278 additions & 0 deletions src/easyscience/base_classes/model_collection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
# SPDX-FileCopyrightText: 2025 EasyScience contributors <core@easyscience.software>
# SPDX-License-Identifier: BSD-3-Clause
# © 2021-2025 Contributors to the EasyScience project <https://github.com/easyScience/EasyScience

from __future__ import annotations

from collections.abc import MutableSequence
from typing import TYPE_CHECKING
from typing import Any
from typing import Callable
from typing import Iterable
from typing import List
from typing import Optional
from typing import TypeVar
from typing import overload

from .model_base import ModelBase
from .new_base import NewBase

if TYPE_CHECKING:
pass

# Type alias for interface
InterfaceType = 'InterfaceFactoryTemplate | None'

T = TypeVar('T', bound=NewBase)


class ModelCollection(ModelBase, MutableSequence[T]):
"""
A collection class for NewBase/ModelBase objects.
This provides list-like functionality while maintaining EasyScience features
like serialization and interface bindings.
"""

def __init__(
self,
*args: NewBase,
interface: InterfaceType = None,
unique_name: Optional[str] = None,
display_name: Optional[str] = None,
):
"""
Initialize the ModelCollection.

:param args: Initial items to add to the collection
:param interface: Optional interface for bindings
:param unique_name: Optional unique name for the collection
:param display_name: Optional display name for the collection
"""
super().__init__(unique_name=unique_name, display_name=display_name)
self._data: List[NewBase] = []
self._interface: InterfaceType = None

# Add initial items
for item in args:
if isinstance(item, list):
for sub_item in item:
self._add_item(sub_item)
else:
self._add_item(item)

# Set interface after adding items so it propagates
if interface is not None:
self.interface = interface

def _add_item(self, item: Any) -> 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)

Check warning on line 80 in src/easyscience/base_classes/model_collection.py

View check run for this annotation

Codecov / codecov/patch

src/easyscience/base_classes/model_collection.py#L80

Added line #L80 was not covered by tests

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, 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
Loading
Loading