Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,14 @@ my_list.events.removed.connect(lambda i, val: print(f"Removed {val} at index {i}

my_list.append(6) # Output: Inserted 6 at index 5
my_list.pop() # Output: Removed 6 at index 5

# the `batch_*` signals bracket a contiguous batch with a single (start, stop) range,
# so e.g. `extend` emits `batch_inserting`/`batch_inserted` once
# while `inserted` still fires once per item.
my_list.events.batch_inserting.connect(
lambda start, stop: print(f"Inserting rows [{start}:{stop}]")
)
my_list.extend([7, 8, 9]) # Output: Inserting rows [5:8] (+ 3 per-item Inserted lines)
```

See the
Expand Down
5 changes: 4 additions & 1 deletion docs/guides/dataclasses.md
Original file line number Diff line number Diff line change
Expand Up @@ -522,10 +522,13 @@ class Project:
project = Project()
project.events.connect(lambda info: print(f"{info.signal.name}: {info.args} {info.path}"))

# Add a person to the list - EventedList emits two events here:
# Add a person to the list - EventedList emits the per-item events, bracketed by the
# contiguous-range batch_* events (useful for handling a batch as a single update):
project.team_members.append(Person(name="Bob"))
# batch_inserting: (0, 1) (.team_members, [0])
# inserting: (0,) (.team_members, [0])
# inserted: (0, Person(name='Bob', age=0)) (.team_members, [0])
# batch_inserted: (0, 1, [Person(name='Bob', age=0)]) (.team_members, [0])

# Change a person in the list - this also bubbles up
project.team_members[0].age = 25
Expand Down
140 changes: 134 additions & 6 deletions src/psygnal/containers/_evented_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@
interface, and call one of those 4 methods. So if you override a method, you
MUST make sure that all the appropriate events are emitted. (Tests should
cover this in test_evented_list.py)

`extend` and `clear` *are* re-implemented here, but only to bracket the whole
operation with a single `batch_*` (start, stop) range. They still funnel every
item through `insert` / `__delitem__`, so the rule above is preserved: overriding
`insert` or `__delitem__` remains sufficient to see every mutation.
"""

from __future__ import annotations
Expand Down Expand Up @@ -49,6 +54,25 @@
Index: TypeAlias = int | slice


def _contiguous_runs(indices: Iterable[int]) -> Iterable[tuple[int, int]]:
"""Yield `(start, stop)` half-open runs of contiguous integers, highest first.

e.g. `[1, 2, 3, 5, 6]` -> `(5, 7)`, `(1, 4)`. Runs are yielded in descending
order so that deleting later (higher) blocks leaves earlier indices valid.
"""
ordered = sorted(set(indices), reverse=True)
if not ordered:
return
stop = ordered[0] + 1
prev = ordered[0]
for idx in ordered[1:]:
if idx != prev - 1:
yield prev, stop
stop = idx + 1
prev = idx
yield prev, stop


class ListSignalInstance(SignalInstance):
def _psygnal_relocate_info_(self, emission_info: EmissionInfo) -> EmissionInfo:
"""Relocate the emission info to the index being modified.
Expand All @@ -74,6 +98,38 @@ class ListEvents(SignalGroup):
"""`(index)` emitted before an item is removed at `index`"""
removed = ListSignal(int, object)
"""`(index, value)` emitted after `value` is removed at `index`"""
batch_inserting = ListSignal(int, int)
"""`(start, stop)` emitted once before a contiguous block of items is inserted
into the half-open range `[start, stop)`.

This brackets the per-item `inserting` events (which still fire for each item), so
that a batch insert (e.g. `extend`/`+=`) can be handled with a single update. A
single insert is just a length-1 range.

Note that the `batch_*` signals track *structural* changes only; slice assignment
(`el[a:b] = ...`) may change the length of the list but emits only `changed`."""
batch_inserted = ListSignal(int, int, object)
"""`(start, stop, values)` emitted once after a contiguous block of items has been
inserted into the half-open range `[start, stop)` (`values` is the inserted
`list`).

`insert` always pairs this with its `batch_inserting`. `extend` does too, unless
`_pre_insert` rejects a value part way through the batch, in which case the
exception propagates and the batch is left unterminated (the list is partially
extended, as it is without the batch signals)."""
batch_removing = ListSignal(int, int)
"""`(start, stop)` emitted once before a contiguous block of items is removed from
the half-open range `[start, stop)`.

Brackets the per-item `removing` events. Non-contiguous removals (e.g. a strided
slice) emit this once per contiguous block, highest block first.

As with `batch_inserting`, slice assignment emits only `changed`."""
batch_removed = ListSignal(int, int, object)
"""`(start, stop, values)` emitted once after a contiguous block of items has been
removed from the half-open range `[start, stop)` (`values` is the removed `list`,
in list order, even though items are popped highest-index first).
"""
moving = ListSignal(int, int)
"""`(index, new_index)` emitted before an item is moved from `index` to
`new_index`"""
Expand Down Expand Up @@ -130,6 +186,8 @@ def __init__(
self._data: list[_T] = []
self._hashable = hashable
self._child_events = child_events
# >0 while extend() is bracketing a batch, so insert() doesn't emit its own
self._batch_depth = 0
self.events = ListEvents(instance=self)
self.extend(data)

Expand All @@ -142,12 +200,66 @@ def __init__(

def insert(self, index: int, value: _T) -> None:
"""Insert `value` before index."""
# `_pre_insert` may reject the value; run it before emitting anything so a
# rejected value never leaves an unterminated batch_inserting behind.
_value = self._pre_insert(value)
if self._batch_depth:
# inside extend(), which brackets the whole batch itself
self._insert_one(index, value, _value)
return
# normalize for the (range-aware) batch_* signals; the per-item events
# keep emitting the raw `index` exactly as before.
norm = max(0, len(self) + index) if index < 0 else min(index, len(self))
if self.events.batch_inserting:
self.events.batch_inserting.emit(norm, norm + 1)
self._insert_one(index, value, _value)
if self.events.batch_inserted:
self.events.batch_inserted.emit(norm, norm + 1, [value])

def extend(self, values: Iterable[_T]) -> None:
"""Extend list by appending all items from `values`.

Overrides `MutableSequence.extend` (which appends one at a time) so the whole
batch is bracketed by a single `batch_inserting`/`batch_inserted` pair. Items
still go through `insert` one by one, emitting the per-item events.
"""
values = list(values)
if not values:
return
start, stop = len(self), len(self) + len(values)
if self.events.batch_inserting:
self.events.batch_inserting.emit(start, stop)
self._batch_depth += 1
try:
for i, value in enumerate(values):
self.insert(start + i, value)
finally:
self._batch_depth -= 1
if self.events.batch_inserted:
self.events.batch_inserted.emit(start, stop, values)

def _insert_one(self, index: int, value: _T, _value: _T) -> None:
"""Insert `_value` (the `_pre_insert` result), emitting the per-item events.

`value` is the original, un-transformed object; it is what the `inserted`
event and `_post_insert` receive.
"""
self.events.inserting.emit(index)
self._data.insert(index, _value)
self.events.inserted.emit(index, value)
self._post_insert(value)

def clear(self) -> None:
"""Remove all items from the list.

Overrides `MutableSequence.clear` (which pops one at a time) so the whole
list is removed as a single contiguous block (one `batch_removing`/
`batch_removed` pair). Items still go through `__delitem__`, so the per-item
`removing`/`removed` events fire for each, highest index first, as before.
"""
if self._data:
del self[:]

@overload
def __getitem__(self, key: int) -> _T: ...

Expand Down Expand Up @@ -185,12 +297,28 @@ def __setitem__(self, key: Index, value: _T | Iterable[_T]) -> None:

def __delitem__(self, key: Index) -> None:
"""Delete self[key]."""
# delete from the end
for parent, index in sorted(self._delitem_indices(key), reverse=True):
parent.events.removing.emit(index)
parent._pre_remove(index)
item = parent._data.pop(index)
self.events.removed.emit(index, item)
# group indices by their (possibly nested) parent list
by_parent: dict[int, tuple[EventedList[_T], list[int]]] = {}
for parent, index in self._delitem_indices(key):
by_parent.setdefault(id(parent), (parent, []))[1].append(index)

for parent, indices in by_parent.values():
# bracket each contiguous block with batch_removing/batch_removed, while
# still emitting the per-item removing/removed events (highest index
# first, so lower indices stay valid as we go).
for start, stop in _contiguous_runs(indices):
if parent.events.batch_removing:
parent.events.batch_removing.emit(start, stop)
items: list[_T] = []
for index in range(stop - 1, start - 1, -1):
parent.events.removing.emit(index)
parent._pre_remove(index)
item = parent._data.pop(index)
items.append(item)
parent.events.removed.emit(index, item)
if parent.events.batch_removed:
items.reverse() # popped highest-first; report in list order
parent.events.batch_removed.emit(start, stop, items)

def _delitem_indices(self, key: Index) -> Iterable[tuple[EventedList[_T], int]]:
# returning (self, int) allows subclasses to pass nested members
Expand Down
Loading
Loading