Source code for modelly.time.signals.batching

"""Batching and suppression of signals for modelly signals.

This module provides context managers for controlling signal dispatch:

- batch(): Buffers signals and dispatches aggregated results on exit
- suppress(): Silently drops all signals during the context

Both batch() and suppress() are used as context managers within HasEmitters. They only
batch or suppress signals emitted by the instance within which they are invoked.

It also provides a functools.singledispatch aggregation system for different signal types, making
it easy for users to add aggregations for their own custom signal types::

    @aggregate.register(MySignalType)
    def _my_aggregate(signal_type, signals, value=None):
        ...

Notes:
    During a batch, computed properties may return stale cached values because
    their triggering signals are deferred. At flush, aggregated signals dispatch
    normally and mark computed properties dirty.

    During suppress, computed properties may become permanently stale because
    their triggering signals are dropped entirely.]]

    @aggregate.register is global to the python process, and it's non-trivial
    to remove it, although you can overwrite it. See also functools.singledispatch for
    more details.

"""

from __future__ import annotations

import functools
from typing import TYPE_CHECKING, Any

from modelly.time.signals.types import ListSignals, ObservableSignals
from modelly.time.signals.util import Message, SignalType

if TYPE_CHECKING:
    from modelly.time.signals.core import HasEmitters

__all__ = ["aggregate"]


[docs] @functools.singledispatch def aggregate( signal_type: SignalType, signals: list[Message], value: Any = None ) -> list[Message]: """Default aggregation: keep all signals as-is. Users can register custom aggregators for their own signal types:: @aggregate.register(MySignalType) def _my_aggregate(signal_type, signals, value=None): ... Notes: we need signal_type as the first argument because that is how singledispatch can determine which handler to call. Args: signal_type: the type of signal (used for dispatch) signals: list of buffered Message objects value: the attribute value captured at the time of the first signal, or None if not captured Returns: list of Message objects to dispatch """ return signals
@aggregate.register(ObservableSignals) def _aggregate_observable( signal_type: ObservableSignals, signals: list[Message], value: Any = None, ) -> list[Message]: """Aggregate ObservableSignals: keep old from first, new from last. If old == new after aggregation, return empty list (no net change). """ if not signals: return [] first = signals[0] last = signals[-1] old = first.additional_kwargs["old"] new = last.additional_kwargs["new"] if old == new: return [] return [ Message( name=first.name, owner=first.owner, signal_type=ObservableSignals.CHANGED, additional_kwargs={"old": old, "new": new}, ) ] @aggregate.register(ListSignals) def _aggregate_list( signal_type: ListSignals, signals: list[Message], value: Any = None, ) -> list[Message]: """Aggregate ListSignals: collapse into a single SET signal. ``value`` is the pre-batch list state, captured by SignalingList before the first mutation. Reads the current list state at flush time for ``new``. If old == new after aggregation, return empty list. """ if not signals: return [] old = list(value) if value is not None else [] # Current list state at flush time owner = signals[-1].owner name = signals[-1].name new = list(getattr(owner, name)) if old == new: return [] return [ Message( name=name, owner=owner, signal_type=ListSignals.SET, additional_kwargs={"old": old, "new": new}, ) ] # -- Context managers --------------------------------------------------------- class _BatchContext: """Context manager that batches signals for a HasEmitters instance. Signals emitted during the batch are buffered. On exit, they are aggregated per observable name and dispatched as consolidated signals. Nesting is supported: inner batches merge their buffers into the outer batch, and only the outermost batch aggregates and dispatches. """ __slots__ = ["_captured_values", "_previous", "buffer", "instance"] def __init__(self, instance: HasEmitters): self.instance = instance self._previous: _BatchContext | None = None self.buffer: dict[ str, list[Message] ] = {} # we cannot use defaultdict here because of snapshot on first entry self._captured_values: dict[str, Any] = {} def __enter__(self): self._previous = self.instance._batch_context self.instance._batch_context = self self.instance._quiet = False return self def capture(self, signal: Message): """Capture a signal and add it to the buffer. On the first signal for a given observable name, stores the current attribute value for later use by ``aggregate`` (unless already snapshotted before mutation, e.g. by SignalingList). Args: signal: the Message to buffer """ name = signal.name if name not in self.buffer: self.buffer[name] = [] # Only capture if not already snapshotted before mutation if name not in self._captured_values: current_value = getattr(signal.owner, name, None) try: self._captured_values[name] = list(current_value) except TypeError: self._captured_values[name] = current_value self.buffer[name].append(signal) def __exit__(self, exc_type, exc_val, exc_tb): self.instance._batch_context = self._previous if exc_type is not None: # On exception, discard buffer return False if self._previous is not None: # Nested batch: merge buffer into parent for name, signals in self.buffer.items(): if name not in self._previous.buffer: self._previous.buffer[name] = [] # Transfer captured value if parent doesn't have one yet if ( name in self._captured_values and name not in self._previous._captured_values ): self._previous._captured_values[name] = self._captured_values[ name ] self._previous.buffer[name].extend(signals) else: # Outermost batch: aggregate and dispatch self._flush() return False def _flush(self): """Aggregate buffered signals and dispatch them.""" for name, signals in self.buffer.items(): if not signals: continue signal_type = signals[0].signal_type value = self._captured_values.get(name, None) aggregated = aggregate(signal_type, signals, value=value) for signal in aggregated: key = (signal.name, signal.signal_type) if key in self.instance.subscribers: self.instance._modelly_notify(signal) def capture_original_value_once(self, name, value): """Store the original value of an observable while batching. Args: name: the name of the observable value: the original value The value will be passed to the aggregator function on flushing and can then be used to determine if there has been a change from old to new while batching. """ if name not in self._captured_values: self._captured_values[name] = value class _SuppressContext: """Context manager that suppresses all signals for a HasEmitters instance. No signals are emitted, buffered, or dispatched during suppress. Nesting is supported: only the outermost exit restores signal dispatch. """ __slots__ = ["_previous", "instance"] def __init__(self, instance: HasEmitters): self.instance = instance self._previous: bool = False def __enter__(self): self._previous = self.instance._suppress self.instance._suppress = True self.instance._quiet = False return self def __exit__(self, exc_type, exc_val, exc_tb): self.instance._suppress = self._previous return False