From 283ec04edde73b37d57c91c48e2d81ab0fa393f8 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 10 Jun 2026 10:44:38 -0400 Subject: [PATCH 1/2] feat: Add batch signal support for contiguous item insertions and removals in EventedList --- README.md | 8 + docs/guides/dataclasses.md | 5 +- src/psygnal/containers/_evented_list.py | 102 +++++++- .../containers/_selectable_evented_list.py | 6 +- tests/containers/test_evented_list.py | 226 +++++++++++++++--- 5 files changed, 305 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index aec492b5..303b38a3 100644 --- a/README.md +++ b/README.md @@ -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 `items_*` signals bracket a contiguous batch with a single (start, stop) range, +# so e.g. `extend` emits `items_inserting`/`items_inserted` once +# while `inserted` still fires once per item. +my_list.events.items_inserting.connect( + lambda start, stop: print(f"Inserting rows [{start}:{stop}]") +) +my_list.extend([7, 8, 9]) # Output: Inserting rows [2:5] (+ 3 per-item Inserted lines) ``` See the diff --git a/docs/guides/dataclasses.md b/docs/guides/dataclasses.md index 3bd8c5e2..5d103480 100644 --- a/docs/guides/dataclasses.md +++ b/docs/guides/dataclasses.md @@ -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 items_* events (useful for batch updates, e.g. driving a Qt model): project.team_members.append(Person(name="Bob")) +# items_inserting: (0, 1) (.team_members, [0]) # inserting: (0,) (.team_members, [0]) # inserted: (0, Person(name='Bob', age=0)) (.team_members, [0]) +# items_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 diff --git a/src/psygnal/containers/_evented_list.py b/src/psygnal/containers/_evented_list.py index ddf7bd20..0771c603 100644 --- a/src/psygnal/containers/_evented_list.py +++ b/src/psygnal/containers/_evented_list.py @@ -49,6 +49,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. @@ -74,6 +93,29 @@ 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`""" + items_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. Maps directly onto Qt's + `beginInsertRows(parent, start, stop - 1)`.""" + items_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`). Maps onto Qt's `endInsertRows()`.""" + items_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. Maps directly onto + Qt's `beginRemoveRows(parent, start, stop - 1)`.""" + items_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`). + Maps onto Qt's `endRemoveRows()`.""" moving = ListSignal(int, int) """`(index, new_index)` emitted before an item is moved from `index` to `new_index`""" @@ -142,12 +184,48 @@ def __init__( def insert(self, index: int, value: _T) -> None: """Insert `value` before index.""" + # normalize for the (range-aware) items_* 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)) + self.events.items_inserting.emit(norm, norm + 1) + self._insert_one(index, value) + self.events.items_inserted.emit(norm, norm + 1, [value]) + + def extend(self, values: Iterable[_T]) -> None: + """Extend list by appending all items from `values`.""" + values = list(values) + if not values: + return + start, stop = len(self), len(self) + len(values) + self.events.items_inserting.emit(start, stop) + for i, value in enumerate(values): + self._insert_one(start + i, value) + self.events.items_inserted.emit(start, stop, values) + + def _insert_one(self, index: int, value: _T) -> None: + """Insert a single `value`, emitting the per-item `inserting`/`inserted`. + + This is the per-item primitive that `insert`/`extend` funnel through; the + contiguous-range `items_inserting`/`items_inserted` events are emitted by the + callers, bracketing one or more `_insert_one` calls. + """ _value = self._pre_insert(value) 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 `items_removing`/ + `items_removed` pair). The per-item `removing`/`removed` events still fire for + each item, highest index first, exactly as before. + """ + if self._data: + del self[:] + @overload def __getitem__(self, key: int) -> _T: ... @@ -185,12 +263,24 @@ 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 items_removing/items_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): + parent.events.items_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) + items.insert(0, parent._data.pop(index)) + parent.events.removed.emit(index, items[0]) + parent.events.items_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 diff --git a/src/psygnal/containers/_selectable_evented_list.py b/src/psygnal/containers/_selectable_evented_list.py index bebe8da2..e29258c3 100644 --- a/src/psygnal/containers/_selectable_evented_list.py +++ b/src/psygnal/containers/_selectable_evented_list.py @@ -51,9 +51,9 @@ def __init__( def _on_item_removed(self, idx: int, obj: Any) -> None: self.selection.discard(obj) - def insert(self, index: int, value: _T) -> None: - """Insert item(s) into the list and update the selection.""" - super().insert(index, value) + def _insert_one(self, index: int, value: _T) -> None: + """Insert an item into the list and update the selection.""" + super()._insert_one(index, value) if self._activate_on_insert: self.selection.active = value diff --git a/tests/containers/test_evented_list.py b/tests/containers/test_evented_list.py index eefda540..9156310b 100644 --- a/tests/containers/test_evented_list.py +++ b/tests/containers/test_evented_list.py @@ -22,53 +22,66 @@ def test_list(regular_list): return test_list +# per-item events bracketed by the contiguous-block items_* events +INSERT = ("items_inserting", "inserting", "inserted", "items_inserted") +REMOVE = ("items_removing", "removing", "removed", "items_removed") + + +def _remove_block(n: int) -> tuple[str, ...]: + # one contiguous block of N removals: + # items_removing, (removing, removed)*N, items_removed + return ("items_removing", *(("removing", "removed") * n), "items_removed") + + +REMOVE_BLOCK2 = _remove_block(2) + + @pytest.mark.parametrize( "meth", [ # METHOD, ARGS, EXPECTED EVENTS # primary interface - ("insert", (2, 10), ("inserting", "inserted")), # create + ("insert", (2, 10), INSERT), # create ("__getitem__", (2,), ()), # read ("__setitem__", (2, 3), ("changed",)), # update ("__setitem__", (slice(2), [1, 2]), ("changed",)), # update slice ("__setitem__", (slice(2, 2), [1, 2]), ("changed",)), # update slice - ("__delitem__", (2,), ("removing", "removed")), # delete - ( - "__delitem__", - (slice(2),), - ("removing", "removed") * 2, - ), - ("__delitem__", (slice(0, 0),), ("removing", "removed")), - ( - "__delitem__", - (slice(-3),), - ("removing", "removed") * 2, - ), - ( - "__delitem__", - (slice(-2, None),), - ("removing", "removed") * 2, - ), + ("__delitem__", (2,), REMOVE), # delete + ("__delitem__", (slice(2),), REMOVE_BLOCK2), # contiguous block + ("__delitem__", (slice(0, 0),), ()), # empty slice -> no emission + ("__delitem__", (slice(-3),), REMOVE_BLOCK2), + ("__delitem__", (slice(-2, None),), REMOVE_BLOCK2), + # non-contiguous removal -> one bracketed block per contiguous run + ("__delitem__", (slice(None, None, 2),), REMOVE * 3), # inherited interface - ("append", (3,), ("inserting", "inserted")), - ("clear", (), ("removing", "removed") * 5), + ("append", (3,), INSERT), + ("clear", (), _remove_block(5)), # whole list removed as one block ("count", (3,), ()), - ("extend", ([7, 8, 9],), ("inserting", "inserted") * 3), + ( + "extend", + ([7, 8, 9],), + ("items_inserting", *(("inserting", "inserted") * 3), "items_inserted"), + ), ("index", (3,), ()), - ("pop", (-2,), ("removing", "removed")), - ("remove", (3,), ("removing", "removed")), + ("pop", (-2,), REMOVE), + ("remove", (3,), REMOVE), ("reverse", (), ("reordered",)), - ("__add__", ([7, 8, 9],), ()), - ("__iadd__", ([7, 9],), ("inserting", "inserted") * 2), - ("__radd__", ([7, 9],), ("inserting", "inserted") * 2), + ("__add__", ([7, 8, 9],), ()), # operates on a copy + ( + "__iadd__", + ([7, 9],), + ("items_inserting", *(("inserting", "inserted") * 2), "items_inserted"), + ), + ("__radd__", ([7, 9],), ()), # does not mutate self # sort? ], ids=lambda x: x[0], ) def test_list_interface_parity(test_list, regular_list, meth): - test_list.events = cast("Mock", test_list.events) - method_name, args, expected = meth + received: list[str] = [] + test_list.events.connect(lambda info: received.append(info.signal.name)) + test_list_method = getattr(test_list, method_name) assert tuple(test_list) == tuple(regular_list) if hasattr(regular_list, method_name): @@ -78,9 +91,7 @@ def test_list_interface_parity(test_list, regular_list, meth): else: test_list_method(*args) # smoke test - for c, expect in zip(test_list.events.call_args_list, expected, strict=False): - event = c.args[0] - assert event.type == expect + assert tuple(received) == expected def test_delete(test_list): @@ -319,11 +330,20 @@ class E: assert root == [e_obj] e_obj.test.emit("hi") - assert mock.call_count == 3 + # items_inserting + inserting + inserted + items_inserted + the child event + assert mock.call_count == 5 expected = [ + call( + EmissionInfo(root.events.items_inserting, (0, 1), path=(PathStep(index=0),)) + ), call(EmissionInfo(root.events.inserting, (0,), path=(PathStep(index=0),))), call(EmissionInfo(root.events.inserted, (0, e_obj), path=(PathStep(index=0),))), + call( + EmissionInfo( + root.events.items_inserted, (0, 1, [e_obj]), path=(PathStep(index=0),) + ) + ), call( EmissionInfo( e_obj.test, ("hi",), path=(PathStep(index=0), PathStep(attr="test")) @@ -357,16 +377,26 @@ def __init__(self): e_obj.events.test2.emit("hi") assert [c[0][0].signal.name for c in mock.call_args_list] == [ + "items_inserting", "inserting", "inserted", + "items_inserted", "test2", # This is now the direct child signal, not child_event ] # when an object in the list owns an emitter group, then any emitter in that group # will also be detected, and the child event will be emitted directly with path info expected = [ + call( + EmissionInfo(root.events.items_inserting, (0, 1), path=(PathStep(index=0),)) + ), call(EmissionInfo(root.events.inserting, (0,), path=(PathStep(index=0),))), call(EmissionInfo(root.events.inserted, (0, e_obj), path=(PathStep(index=0),))), + call( + EmissionInfo( + root.events.items_inserted, (0, 1, [e_obj]), path=(PathStep(index=0),) + ) + ), call(EmissionInfo(e_obj.events.test2, ("hi",), path=(PathStep(index=0),))), ] @@ -381,3 +411,135 @@ def test_copy_no_sync(): l2 = copy(l1) l1.append(4) assert len(l2) == 3 + + +def test_items_inserted_emits_once_for_batch(): + """The items_* batch signals fire once per contiguous block; per-item N times.""" + el = EventedList([0, 1, 2]) + items_inserting = Mock() + items_inserted = Mock() + inserted = Mock() + el.events.items_inserting.connect(items_inserting) + el.events.items_inserted.connect(items_inserted) + el.events.inserted.connect(inserted) + + el.extend([3, 4, 5]) + assert el == [0, 1, 2, 3, 4, 5] + # batch signal fires exactly once over the whole contiguous range... + items_inserting.assert_called_once_with(3, 6) + items_inserted.assert_called_once_with(3, 6, [3, 4, 5]) + # ...while the per-item signal still fires once per item (unchanged contract) + assert inserted.call_args_list == [call(3, 3), call(4, 4), call(5, 5)] + + # a single insert is just a length-1 range + items_inserting.reset_mock() + items_inserted.reset_mock() + el.insert(0, 99) + items_inserting.assert_called_once_with(0, 1) + items_inserted.assert_called_once_with(0, 1, [99]) + + +def test_per_item_signals_unchanged(): + """Legacy (index)/(index, value) callbacks behave exactly as before.""" + el = EventedList([0, 1, 2]) + inserting = Mock() + inserted = Mock() + removed = Mock() + el.events.inserting.connect(inserting) + el.events.inserted.connect(inserted) + el.events.removed.connect(removed) + + el.append(9) + inserting.assert_called_once_with(3) + inserted.assert_called_once_with(3, 9) + + # a batch still emits the per-item event for *every* item, not just once + inserting.reset_mock() + inserted.reset_mock() + el.extend([10, 11]) + assert inserting.call_args_list == [call(4), call(5)] + assert inserted.call_args_list == [call(4, 10), call(5, 11)] + + del el[0] + removed.assert_called_once_with(0, 0) + + +@pytest.mark.parametrize("index", [-100, -2, -1, 0, 1, 2, 100]) +def test_insert_index_parity(index): + """insert() with negative/out-of-range indices matches builtin list.""" + el = EventedList([0, 1, 2]) + ref = [0, 1, 2] + el.insert(index, 9) + ref.insert(index, 9) + assert el == ref + + +def test_items_removed_emits_per_block(): + """Contiguous removals emit one block; non-contiguous emit once per block.""" + el = EventedList([0, 1, 2, 3, 4, 5]) + items_removing = Mock() + items_removed = Mock() + el.events.items_removing.connect(items_removing) + el.events.items_removed.connect(items_removed) + + # contiguous slice -> single block + del el[1:4] + assert el == [0, 4, 5] + items_removing.assert_called_once_with(1, 4) + items_removed.assert_called_once_with(1, 4, [1, 2, 3]) + + # non-contiguous slice -> one block per contiguous run, highest first + el[:] = [0, 1, 2, 3, 4, 5] + items_removing.reset_mock() + items_removed.reset_mock() + del el[::2] # indices 0, 2, 4 + assert el == [1, 3, 5] + assert items_removing.call_args_list == [call(4, 5), call(2, 3), call(0, 1)] + assert items_removed.call_args_list == [ + call(4, 5, [4]), + call(2, 3, [2]), + call(0, 1, [0]), + ] + + +def test_clear_emits_single_block(): + """clear() removes the whole list as one bracketed block.""" + el = EventedList([0, 1, 2, 3]) + items_removing = Mock() + items_removed = Mock() + removed = Mock() + el.events.items_removing.connect(items_removing) + el.events.items_removed.connect(items_removed) + el.events.removed.connect(removed) + + el.clear() + assert el == [] + items_removing.assert_called_once_with(0, 4) + items_removed.assert_called_once_with(0, 4, [0, 1, 2, 3]) + # per-item still fires for each, highest index first (unchanged from before) + assert removed.call_args_list == [call(3, 3), call(2, 2), call(1, 1), call(0, 0)] + + +def test_items_signals_drive_qt_style_model(): + """items_* (start, stop) ranges wire directly onto begin/end{Insert,Remove}Rows.""" + el = EventedList([0, 1, 2]) + calls: list[tuple] = [] + + el.events.items_inserting.connect( + lambda start, stop: calls.append(("beginInsertRows", start, stop - 1)) + ) + el.events.items_inserted.connect(lambda *_: calls.append(("endInsertRows",))) + el.events.items_removing.connect( + lambda start, stop: calls.append(("beginRemoveRows", start, stop - 1)) + ) + el.events.items_removed.connect(lambda *_: calls.append(("endRemoveRows",))) + + el.extend([3, 4]) # a single bracketed block for the whole batch + del el[0:2] + + assert calls == [ + ("beginInsertRows", 3, 4), # inclusive last row, as Qt expects + ("endInsertRows",), + ("beginRemoveRows", 0, 1), + ("endRemoveRows",), + ] From eb6c5631cef7129c2e25531c6d5c0af636a81848 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 10 Jun 2026 11:00:20 -0400 Subject: [PATCH 2/2] docs: Clarify comments regarding batch updates in EventedList --- docs/guides/dataclasses.md | 2 +- src/psygnal/containers/_evented_list.py | 10 ++++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/docs/guides/dataclasses.md b/docs/guides/dataclasses.md index 5d103480..666de3cf 100644 --- a/docs/guides/dataclasses.md +++ b/docs/guides/dataclasses.md @@ -523,7 +523,7 @@ project = Project() project.events.connect(lambda info: print(f"{info.signal.name}: {info.args} {info.path}")) # Add a person to the list - EventedList emits the per-item events, bracketed by the -# contiguous-range items_* events (useful for batch updates, e.g. driving a Qt model): +# contiguous-range items_* events (useful for handling a batch as a single update): project.team_members.append(Person(name="Bob")) # items_inserting: (0, 1) (.team_members, [0]) # inserting: (0,) (.team_members, [0]) diff --git a/src/psygnal/containers/_evented_list.py b/src/psygnal/containers/_evented_list.py index 0771c603..4c6a4168 100644 --- a/src/psygnal/containers/_evented_list.py +++ b/src/psygnal/containers/_evented_list.py @@ -99,23 +99,21 @@ class ListEvents(SignalGroup): 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. Maps directly onto Qt's - `beginInsertRows(parent, start, stop - 1)`.""" + single insert is just a length-1 range.""" items_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`). Maps onto Qt's `endInsertRows()`.""" + `list`).""" items_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. Maps directly onto - Qt's `beginRemoveRows(parent, start, stop - 1)`.""" + slice) emit this once per contiguous block, highest block first.""" items_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`). - Maps onto Qt's `endRemoveRows()`.""" + """ moving = ListSignal(int, int) """`(index, new_index)` emitted before an item is moved from `index` to `new_index`"""