Source code for modelly.time.signals.collections

"""Observable collection types that emit signals when modified.

This module extends Modelly's reactive programming capabilities to collection types like
lists. Observable collections emit signals when items are added, removed, or modified,
allowing other components to react to changes in the collection's contents.

The module provides:
- ListSignals: Enum defining signal types for list collections
- ObservableList: A list descriptor that emits signals on modifications
- SignalingList: The underlying list implementation that manages signal emission

These classes enable building models where components need to track and react to
changes in collections of agents, resources, or other model elements.
"""

from collections.abc import Iterable, MutableSequence
from typing import Any

from modelly.time.signals.core import BaseObservable, HasEmitters
from modelly.time.signals.types import ListSignals
from modelly.time.signals.util import SignalType

__all__ = [
    "ObservableList",
]


[docs] class ObservableList(BaseObservable): """An ObservableList that emits signals on changes to the underlying list.""" signal_types: type[SignalType] = ListSignals def __init__(self): """Initialize the ObservableList.""" super().__init__(fallback_value=[]) def __set__(self, instance: "HasEmitters", value: Iterable): """Set the value of the descriptor attribute. Args: instance: The instance on which to set the attribute. value: The value to set the attribute to. """ old_value = getattr(instance, self.private_name, self.fallback_value) # Snapshot into batch context before replacing the list ctx = instance._batch_context if ctx is not None: old_value = [] if old_value is None else list(old_value) ctx.capture_original_value_once(self.public_name, old_value) setattr( instance, self.private_name, SignalingList(value, instance, self.public_name), ) instance.notify( self.public_name, ListSignals.SET, old=old_value, new=value, )
class SignalingList(MutableSequence[Any]): """A standard list that emits signals on changes to the underlying list.""" __slots__ = ["data", "name", "owner"] def __init__(self, iterable: Iterable, owner: HasEmitters, name: str): """Initialize a SignalingList. Args: iterable: initial values in the list owner: the HasEmitters instance on which this list is defined name: the attribute name to which this list is assigned """ self.owner: HasEmitters = owner self.name: str = name self.data = list(iterable) def _snapshot_if_batching(self): """Snapshot list state into the batch context before the first mutation.""" ctx = self.owner._batch_context if ctx is not None: ctx.capture_original_value_once(self.name, list(self.data)) def __setitem__(self, index: int | slice, value: Any) -> None: """Set item(s) by index or slice. Args: index: the index or slice to set value: the item (or iterable for slices) to set """ self._snapshot_if_batching() if isinstance(index, slice): # Normalized bounds are for the signal, not for slicing again. normalized_index = slice(*index.indices(len(self.data))) old_value = self.data[index] new_value = list(value) self.data[index] = new_value self.owner.notify( self.name, ListSignals.REPLACED, index=normalized_index, old=old_value, new=new_value, ) else: if index < 0: index += len(self.data) if not 0 <= index < len(self.data): raise IndexError("list assignment index out of range") old_value = self.data[index] self.data[index] = value self.owner.notify( self.name, ListSignals.REPLACED, index=index, old=old_value, new=value ) def __delitem__(self, index: int | slice) -> None: """Delete item(s) by index or slice. Args: index: the index or slice to delete """ self._snapshot_if_batching() if isinstance(index, slice): normalized_index = slice(*index.indices(len(self.data))) old_value = self.data[index] else: if index < 0: index += len(self.data) if not 0 <= index < len(self.data): raise IndexError("list assignment index out of range") old_value = self.data[index] normalized_index = index del self.data[index] self.owner.notify( self.name, ListSignals.REMOVED, index=normalized_index, old=old_value ) def __getitem__(self, index) -> Any: """Get item at index. Args: index: The index of the item to retrieve Returns: the item at index """ return self.data[index] def __len__(self) -> int: """Return the length of the list.""" return len(self.data) def insert(self, index, value): """Insert value at index. Args: index: the index to insert value into value: the value to insert """ self._snapshot_if_batching() # Normalize before insert: clamp to [0, len] to match list.insert behavior if index < 0: index = max(0, index + len(self.data)) elif index > len(self.data): index = len(self.data) self.data.insert(index, value) self.owner.notify(self.name, ListSignals.INSERTED, index=index, new=value) def append(self, value): """Append value to list. Args: value: the value to append """ self._snapshot_if_batching() index = len(self.data) self.data.append(value) self.owner.notify(self.name, ListSignals.APPENDED, index=index, new=value) def __str__(self): return self.data.__str__() def __repr__(self): return self.data.__repr__()