Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package python-psygnal for openSUSE:Factory 
checked in at 2026-10-26 10:02:03
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-psygnal (Old)
 and      /work/SRC/openSUSE:Factory/.python-psygnal.new.2252680 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "python-psygnal"

Mon Oct 26 10:02:03 2026 rev:10 rq:1392502 version:0.16.1

Changes:
--------
--- /work/SRC/openSUSE:Factory/python-psygnal/python-psygnal.changes    
2026-07-06 12:34:38.352201171 +0200
+++ 
/work/SRC/openSUSE:Factory/.python-psygnal.new.2252680/python-psygnal.changes   
    2026-10-26 10:03:33.920679434 +0100
@@ -1,0 +2,25 @@
+Sun Oct 25 17:43:48 UTC 2026 - Dirk Müller <[email protected]>
+
+- update to 0.16.1:
+  * fix: check if caller is alive then callback
+  * build: support Python 3.15 (and fix missing locals in
+    compiled EmitLoopError on 3.13+)
+- update to 0.16.0:
+  * ci(pre-commit.ci): autoupdate
+  * chore: Limit mypy version and fix typesafety
+  * fix: add missing methods in `_group.pyi` type stub
+  * ci(dependabot): bump the actions group with 3 updates
+  * fix: Fix emmision when signal disconnect itself
+  * ci(pre-commit.ci): autoupdate
+  * fix: Unpin mypy
+  * fix: Improve disconnect behavior for slots with missing_ok
+    option in SignalRelay
+  * ci(dependabot): bump the actions group with 4 updates
+  * ci(pre-commit.ci): autoupdate
+  * feat: Add batch signal support for contiguous item insertions
+    and removals in EventedList
+  * ci(pre-commit.ci): autoupdate
+  * Make mypyc wheel builds reproducible
+  * Preserve child event forwarding policy during replacement
+
+-------------------------------------------------------------------

Old:
----
  psygnal-0.15.1.tar.gz

New:
----
  psygnal-0.16.1.tar.gz

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ python-psygnal.spec ++++++
--- /var/tmp/diff_new_pack.20dxIm/_old  2026-10-26 10:03:34.704712314 +0100
+++ /var/tmp/diff_new_pack.20dxIm/_new  2026-10-26 10:03:34.706712398 +0100
@@ -18,7 +18,7 @@
 
 %{?sle15_python_module_pythons}
 Name:           python-psygnal
-Version:        0.15.1
+Version:        0.16.1
 Release:        0
 Summary:        Fast python callback/event system modeled after Qt Signals
 License:        BSD-3-Clause

++++++ psygnal-0.15.1.tar.gz -> psygnal-0.16.1.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/psygnal-0.15.1/PKG-INFO new/psygnal-0.16.1/PKG-INFO
--- old/psygnal-0.15.1/PKG-INFO 2026-01-04 17:24:27.000000000 +0100
+++ new/psygnal-0.16.1/PKG-INFO 2026-09-11 09:53:18.000000000 +0200
@@ -1,6 +1,6 @@
-Metadata-Version: 2.4
+Metadata-Version: 2.5
 Name: psygnal
-Version: 0.15.1
+Version: 0.16.1
 Summary: Fast python callback/event system modeled after Qt Signals
 Project-URL: homepage, https://github.com/pyapp-kit/psygnal
 Project-URL: repository, https://github.com/pyapp-kit/psygnal
@@ -17,6 +17,7 @@
 Classifier: Programming Language :: Python :: 3.12
 Classifier: Programming Language :: Python :: 3.13
 Classifier: Programming Language :: Python :: 3.14
+Classifier: Programming Language :: Python :: 3.15
 Classifier: Typing :: Typed
 Requires-Python: >=3.10
 Provides-Extra: proxy
@@ -67,21 +68,25 @@
 ```python
 from psygnal import Signal
 
+
 class MyObject:
     # define one or more signals as class attributes
     value_changed = Signal(str)
 
+
 # create an instance
 my_obj = MyObject()
 
+
 # You (or others) can connect callbacks to your signals
 @my_obj.value_changed.connect
 def on_change(new_value: str):
     print(f"The value changed to {new_value}!")
 
+
 # The object may now emit signals when appropriate,
 # (for example in a setter method)
-my_obj.value_changed.emit('hi')  # prints "The value changed to hi!"
+my_obj.value_changed.emit("hi")  # prints "The value changed to hi!"
 ```
 
 Much more detail available in the 
[documentation](https://psygnal.readthedocs.io/)!
@@ -99,19 +104,23 @@
 from psygnal import evented
 from dataclasses import dataclass
 
+
 @evented
 @dataclass
 class Person:
     name: str
     age: int = 0
 
-person = Person('John', age=30)
+
+person = Person("John", age=30)
+
 
 # connect callbacks
 @person.events.age.connect
 def _on_age_change(new_age: str):
     print(f"Age changed to {new_age}")
 
+
 person.age = 31  # prints: Age changed to 31
 ```
 
@@ -132,6 +141,14 @@
 
 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
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/psygnal-0.15.1/README.md new/psygnal-0.16.1/README.md
--- old/psygnal-0.15.1/README.md        2026-01-04 17:24:27.000000000 +0100
+++ new/psygnal-0.16.1/README.md        2026-09-11 09:53:18.000000000 +0200
@@ -40,21 +40,25 @@
 ```python
 from psygnal import Signal
 
+
 class MyObject:
     # define one or more signals as class attributes
     value_changed = Signal(str)
 
+
 # create an instance
 my_obj = MyObject()
 
+
 # You (or others) can connect callbacks to your signals
 @my_obj.value_changed.connect
 def on_change(new_value: str):
     print(f"The value changed to {new_value}!")
 
+
 # The object may now emit signals when appropriate,
 # (for example in a setter method)
-my_obj.value_changed.emit('hi')  # prints "The value changed to hi!"
+my_obj.value_changed.emit("hi")  # prints "The value changed to hi!"
 ```
 
 Much more detail available in the 
[documentation](https://psygnal.readthedocs.io/)!
@@ -72,19 +76,23 @@
 from psygnal import evented
 from dataclasses import dataclass
 
+
 @evented
 @dataclass
 class Person:
     name: str
     age: int = 0
 
-person = Person('John', age=30)
+
+person = Person("John", age=30)
+
 
 # connect callbacks
 @person.events.age.connect
 def _on_age_change(new_age: str):
     print(f"Age changed to {new_age}")
 
+
 person.age = 31  # prints: Age changed to 31
 ```
 
@@ -105,6 +113,14 @@
 
 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
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/psygnal-0.15.1/pyproject.toml 
new/psygnal-0.16.1/pyproject.toml
--- old/psygnal-0.15.1/pyproject.toml   2026-01-04 17:24:27.000000000 +0100
+++ new/psygnal-0.16.1/pyproject.toml   2026-09-11 09:53:18.000000000 +0200
@@ -21,6 +21,7 @@
     "Programming Language :: Python :: 3.12",
     "Programming Language :: Python :: 3.13",
     "Programming Language :: Python :: 3.14",
+    "Programming Language :: Python :: 3.15",
     "Typing :: Typed",
 ]
 dynamic = ["version"]
@@ -50,7 +51,7 @@
     "anyio",
     "trio",
 ]
-test-typing = [{ include-group = "test-min" }, "pytest-mypy-plugins; 
python_version < '3.14'"]
+test-typing = [{ include-group = "test-min" }, "pytest-mypy-plugins<4; 
python_version < '3.14'"]
 testqt = [{ include-group = "test" }, "pytest-qt", "qtpy"]
 test-codspeed = [{ include-group = "test" }, "pytest-codspeed"]
 docs = [
@@ -68,15 +69,15 @@
     { include-group = "docs" },
     "PyQt6",
     "ipython",
-    "mypy",
+    "mypy >=2.1.0",
     "mypy_extensions",
-    "pre-commit",
     "asv",
     "ruff",
     "typing-extensions",
     "rich>=14.0.0",
     "pdbpp; sys_platform != 'win32'",
     "pytest-benchmark>=5.1.0",
+    "prek>=0.4.3",
 ]
 
 [project.urls]
@@ -99,11 +100,12 @@
 
 [tool.hatch.build.targets.wheel.hooks.mypyc]
 mypy-args = ["--ignore-missing-imports"]
+options = { debug_level = "0" }
 enable-by-default = false
 require-runtime-dependencies = true
 dependencies = [
     "hatch-mypyc>=0.13.0",
-    "mypy",
+    "mypy >=2.1.0",
     "mypy_extensions >=0.4.2",
     "pydantic!=2.10.0",        # typing error in v2.10 prevents mypyc from 
working
     "types-attrs",
@@ -124,10 +126,12 @@
 [tool.cibuildwheel]
 # Skip 32-bit builds & PyPy wheels on all platforms
 skip = ["*-manylinux_i686", "*-musllinux_i686", "*-win32", "pp*"]
-build = ["cp310-*", "cp311-*", "cp312-*", "cp313-*", "cp314-*"]
+build = ["cp310-*", "cp311-*", "cp312-*", "cp313-*", "cp314-*", "cp315-*"]
 test-groups = ["test"]
 test-command = "pytest {project}/tests -v"
-test-skip = ["*-musllinux*", "cp312-win*", "*-macosx_arm64"]
+# cp315: pydantic-core has no cp315 wheels before pydantic 2.14, and manylinux 
has no
+# Rust to build it. 3.15 is still covered by the test.yml matrix. Drop once 
2.14 is out.
+test-skip = ["*-musllinux*", "cp312-win*", "*-macosx_arm64", "cp315-*"]
 build-frontend = "build[uv]"
 
 [[tool.cibuildwheel.overrides]]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/psygnal-0.15.1/src/psygnal/_exceptions.py 
new/psygnal-0.16.1/src/psygnal/_exceptions.py
--- old/psygnal-0.15.1/src/psygnal/_exceptions.py       2026-01-04 
17:24:27.000000000 +0100
+++ new/psygnal-0.16.1/src/psygnal/_exceptions.py       2026-09-11 
09:53:18.000000000 +0200
@@ -4,12 +4,12 @@
 import os
 from contextlib import suppress
 from pathlib import Path
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Any
 
 import psygnal
 
 if TYPE_CHECKING:
-    from collections.abc import Container, Sequence
+    from collections.abc import Container, Mapping, Sequence
 
     from ._signal import SignalInstance
 
@@ -100,7 +100,10 @@
 
             # Then end with the frame that raised the exception
             msg += f"    {_fmt_frame(except_frame)}  # <-- ERROR OCCURRED HERE 
\n"
-            if flocals := except_frame.frame.f_locals:
+            # f_locals may be a FrameLocalsProxy (3.13+), not a dict; access it
+            # via Any so mypyc doesn't insert a (failing) runtime dict check.
+            frame: Any = except_frame.frame
+            if flocals := frame.f_locals:
                 if not os.getenv("PSYGNAL_HIDE_LOCALS"):
                     msg += "\n      Local variables:\n"
                     msg += _fmt_locals(flocals)
@@ -116,7 +119,9 @@
 
 
 def _fmt_locals(
-    f_locals: dict, exclude: Container[str] = ("self", "cls"), name_width: int 
= 20
+    f_locals: Mapping[str, Any],
+    exclude: Container[str] = ("self", "cls"),
+    name_width: int = 20,
 ) -> str:
     lines = []
     for name, value in f_locals.items():
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/psygnal-0.15.1/src/psygnal/_group.py 
new/psygnal-0.16.1/src/psygnal/_group.py
--- old/psygnal-0.15.1/src/psygnal/_group.py    2026-01-04 17:24:27.000000000 
+0100
+++ new/psygnal-0.16.1/src/psygnal/_group.py    2026-09-11 09:53:18.000000000 
+0200
@@ -314,9 +314,16 @@
         ValueError
             If `slot` is not connected and `missing_ok` is False.
         """
+        # A slot may be connected either to the relay itself (via 
`group.connect`)
+        # or directly to the child signals (via `group.connect_direct`).  It is
+        # therefore "connected" as long as it is found in any of those places, 
so
+        # we must not enforce `missing_ok` on each child individually.
+        if slot is not None and not missing_ok and slot not in self:
+            if not any(slot in sig for sig in self._signals.values()):
+                raise ValueError(f"slot is not connected: {slot}")
         for sig in self._signals.values():
-            sig.disconnect(slot, missing_ok)
-        super().disconnect(slot, missing_ok)
+            sig.disconnect(slot, missing_ok=True)
+        super().disconnect(slot, missing_ok=True)
 
     def _slot_index(self, slot: Callable) -> int:
         """Get index of `slot` in `self._slots`. Return -1 if not connected.
@@ -429,6 +436,7 @@
             for name, sig in cls._psygnal_signals.items()
         }
         self._psygnal_relay = SignalRelay(self._psygnal_instances, instance)
+        self._psygnal_child_events_connected = False
 
     def __init_subclass__(
         cls,
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/psygnal-0.15.1/src/psygnal/_group.pyi 
new/psygnal-0.16.1/src/psygnal/_group.pyi
--- old/psygnal-0.15.1/src/psygnal/_group.pyi   2026-01-04 17:24:27.000000000 
+0100
+++ new/psygnal-0.16.1/src/psygnal/_group.pyi   2026-09-11 09:53:18.000000000 
+0200
@@ -1,16 +1,51 @@
 # This stub provides __getattr__ for type checking, which cannot be in the
 # main file because mypyc doesn't support __getattr__ in classes with
 # allow_interpreted_subclasses=True.
-from collections.abc import Iterator, Mapping
-from typing import Any, ClassVar
+import threading
+from collections.abc import Callable, Container, Hashable, Iterator, Mapping
+from contextlib import AbstractContextManager
+from dataclasses import dataclass
+from typing import Any, ClassVar, Literal, SupportsIndex, overload
 
-from psygnal._signal import Signal, SignalInstance
+from psygnal._signal import F, ReducerFunc, Signal, SignalInstance
+from psygnal._weak_callback import RefErrorChoice
+
+@dataclass(slots=True, frozen=True)
+class PathStep:
+    attr: str | None = ...
+    index: SupportsIndex | None = ...
+    key: Hashable | None = ...
+
+@dataclass(slots=True, frozen=True)
+class EmissionInfo:
+    signal: SignalInstance
+    args: tuple[Any, ...]
+    path: tuple[PathStep, ...] = ...
+    def insert_path(self, *path: PathStep) -> EmissionInfo: ...
+    def __iter__(self) -> Iterator[Any]: ...
 
 class SignalRelay(SignalInstance):
     instance: Any
     def __init__(
         self, signals: Mapping[str, SignalInstance], instance: Any = None
     ) -> None: ...
+    def connect_direct(
+        self,
+        slot: Callable | None = ...,
+        *,
+        check_nargs: bool | None = ...,
+        check_types: bool | None = ...,
+        unique: bool | Literal["raise"] = ...,
+        max_args: int | None = ...,
+    ) -> Callable[[Callable], Callable] | Callable: ...
+    def block(self, exclude: Container[str | SignalInstance] = ...) -> None: 
...
+    def unblock(self) -> None: ...
+    def blocked(
+        self, exclude: Container[str | SignalInstance] = ...
+    ) -> AbstractContextManager[None]: ...
+    def disconnect(
+        self, slot: Callable | None = ..., missing_ok: bool = ...
+    ) -> None: ...
 
 class SignalGroup:
     _psygnal_signals: ClassVar[Mapping[str, Signal]]
@@ -18,6 +53,7 @@
     _psygnal_name_conflicts: ClassVar[set[str]]
     _psygnal_aliases: ClassVar[dict[str, str | None]]
     _psygnal_instances: dict[str, SignalInstance]
+    _psygnal_child_events_connected: bool
 
     def __init__(self, instance: Any = None) -> None: ...
     def __init_subclass__(
@@ -37,3 +73,55 @@
     def __iter__(self) -> Iterator[str]: ...
     def __contains__(self, item: str) -> bool: ...
     def __repr__(self) -> str: ...
+    @classmethod
+    def psygnals_uniform(cls) -> bool: ...
+    @classmethod
+    def is_uniform(cls) -> bool: ...
+    def __deepcopy__(self, memo: dict[int, Any]) -> SignalGroup: ...
+    @overload
+    def connect(
+        self,
+        *,
+        thread: threading.Thread | Literal["main", "current"] | None = ...,
+        check_nargs: bool | None = ...,
+        check_types: bool | None = ...,
+        unique: bool | Literal["raise"] = ...,
+        max_args: int | None = ...,
+        on_ref_error: RefErrorChoice = ...,
+        priority: int = ...,
+    ) -> Callable[[F], F]: ...
+    @overload
+    def connect(
+        self,
+        slot: F,
+        *,
+        thread: threading.Thread | Literal["main", "current"] | None = ...,
+        check_nargs: bool | None = ...,
+        check_types: bool | None = ...,
+        unique: bool | Literal["raise"] = ...,
+        max_args: int | None = ...,
+        on_ref_error: RefErrorChoice = ...,
+        priority: int = ...,
+    ) -> F: ...
+    def connect_direct(
+        self,
+        slot: Callable | None = ...,
+        *,
+        check_nargs: bool | None = ...,
+        check_types: bool | None = ...,
+        unique: bool | Literal["raise"] = ...,
+        max_args: int | None = ...,
+    ) -> Callable[[Callable], Callable] | Callable: ...
+    def disconnect(
+        self, slot: Callable | None = ..., missing_ok: bool = ...
+    ) -> None: ...
+    def block(self, exclude: Container[str | SignalInstance] = ...) -> None: 
...
+    def unblock(self) -> None: ...
+    def blocked(
+        self, exclude: Container[str | SignalInstance] = ...
+    ) -> AbstractContextManager[None]: ...
+    def pause(self) -> None: ...
+    def resume(self, reducer: ReducerFunc | None = ..., initial: Any = ...) -> 
None: ...
+    def paused(
+        self, reducer: ReducerFunc | None = ..., initial: Any = ...
+    ) -> AbstractContextManager[None]: ...
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/psygnal-0.15.1/src/psygnal/_group_descriptor.py 
new/psygnal-0.16.1/src/psygnal/_group_descriptor.py
--- old/psygnal-0.15.1/src/psygnal/_group_descriptor.py 2026-01-04 
17:24:27.000000000 +0100
+++ new/psygnal-0.16.1/src/psygnal/_group_descriptor.py 2026-09-11 
09:53:18.000000000 +0200
@@ -189,7 +189,7 @@
         self,
         obj: ref | object,
         attr: str,
-        maxargs: int | None | object = 1,
+        maxargs: int | object | None = 1,
         *,
         on_ref_error: RefErrorChoice = "warn",
         priority: int = 0,
@@ -456,17 +456,23 @@
             # Get the signal for this field
             signal = get_signal(group, name)
 
-            # Fast path: If signal doesn't exist or has no listeners, and 
group has
-            # no listeners, skip all the expensive operations
-            if signal is None or (len(signal) < 1 and not 
len(group._psygnal_relay)):
+            # Skip work only when there are no listeners or child connections
+            # to maintain (or the field has no signal).
+            if signal is None or (
+                len(signal) < 1
+                and not len(group._psygnal_relay)
+                and not group._psygnal_child_events_connected
+            ):
                 return super_setattr(self, name, value)
 
-            # Slow path: We have listeners, so do the full work
+            # Maintain listeners and any existing child connections.
             old_value = getattr(self, name, None)
             with _changes_emitted(self, name, signal, old_value):
                 super_setattr(self, name, value)
-                # Only handle child events for evented fields
-                if is_evented(value):
+                # Maintain child forwarding only after it has been enabled.
+                if group._psygnal_child_events_connected and (
+                    is_evented(old_value) or is_evented(value)
+                ):
                     callback = 
group._psygnal_relay._relay_partial(PathStep(attr=name))
                     _handle_child_event_connections(old_value, value, callback)
 
@@ -554,6 +560,8 @@
         been decorated with `@evented`, or if it has a SignalGroupDescriptor).
         This is useful for nested evented dataclasses, where you want to 
monitor events
         emitted from arbitrarily deep children on the parent object.
+        This also controls forwarding from children assigned after a listener
+        connects. Replaced or removed children stop forwarding events.
         By default True.
     signal_aliases: Mapping[str, str | None] | Callable[[str], str | None] | 
None
         If defined, a mapping between field name and signal name. Field names 
that are
@@ -780,6 +788,8 @@
     if _group is None and (_group := _find_signal_group(obj)) is None:
         return  # pragma: no cover  # not evented
 
+    _group._psygnal_child_events_connected = True
+
     for loc, _ in iter_fields(type(obj), exclude_frozen=True):
         _connect_if_evented(
             getattr(obj, loc, None),
@@ -796,6 +806,7 @@
             check_nargs=False,
             check_types=False,
             on_ref_error="ignore",  # compiled objects are not weakref-able
+            unique=True,
         )
         if recurse:
             connect_child_events(obj, recurse=True, _group=signal_group)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/psygnal-0.15.1/src/psygnal/_queue.py 
new/psygnal-0.16.1/src/psygnal/_queue.py
--- old/psygnal-0.15.1/src/psygnal/_queue.py    2026-01-04 17:24:27.000000000 
+0100
+++ new/psygnal-0.16.1/src/psygnal/_queue.py    2026-09-11 09:53:18.000000000 
+0200
@@ -66,6 +66,9 @@
     def dereference(self) -> Callable | None:
         return self._wrapped.dereference()
 
+    def _is_alive(self) -> bool:
+        return self._wrapped._is_alive()
+
     def __eq__(self, other: object) -> bool:
         """Compare QueuedCallback instances for equality based on wrapped 
callback.
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/psygnal-0.15.1/src/psygnal/_signal.py 
new/psygnal-0.16.1/src/psygnal/_signal.py
--- old/psygnal-0.15.1/src/psygnal/_signal.py   2026-01-04 17:24:27.000000000 
+0100
+++ new/psygnal-0.16.1/src/psygnal/_signal.py   2026-09-11 09:53:18.000000000 
+0200
@@ -827,7 +827,7 @@
         elif isinstance(slot, int):
             self._slots.pop(slot)
         else:
-            self._slots.remove(cast("WeakCallback", slot))
+            self._slots.remove(slot)
 
     def _try_discard(self, callback: WeakCallback, missing_ok: bool = True) -> 
None:
         """Try to discard a callback from the list of slots.
@@ -849,7 +849,7 @@
         self,
         obj: object,
         attr: str,
-        maxargs: int | None | object = _NULL,
+        maxargs: int | object | None = _NULL,
         *,
         on_ref_error: RefErrorChoice = "warn",
         priority: int = 0,
@@ -961,7 +961,7 @@
         self,
         obj: object,
         key: str,
-        maxargs: int | None | object = _NULL,
+        maxargs: int | object | None = _NULL,
         *,
         on_ref_error: RefErrorChoice = "warn",
         priority: int = 0,
@@ -1336,16 +1336,24 @@
 
     def _run_emit_loop_immediate(self) -> None:
         args = self._emit_queue.popleft()
-        for caller in self._slots:
-            caller.cb(args)
+        # NOTE: iterating a copy of self._slots (so that a slot disconnecting
+        # itself doesn't skip the next one) means slots removed *during* this
+        # emit are still in hand. Skip the ones whose weakly-referenced object
+        # was garbage collected: they are already disconnected, and calling 
them
+        # raises ReferenceError.
+        for caller in list(self._slots):
+            if caller._is_alive():
+                caller.cb(args)
 
     def _run_emit_loop_latest_only(self) -> None:
         self._args = args = self._emit_queue.popleft()
-        for caller in self._slots:
+        for caller in list(self._slots):
             if self._recursion_depth < self._max_recursion_depth:
                 # we've already entered a deeper emit loop
                 # we should drop the remaining slots in this round and return
                 break
+            if not caller._is_alive():
+                continue
             self._caller = caller
             caller.cb(args)
 
@@ -1353,8 +1361,9 @@
         i = 0
         while i < len(self._emit_queue):
             args = self._emit_queue[i]
-            for caller in self._slots:
-                caller.cb(args)
+            for caller in list(self._slots):
+                if caller._is_alive():
+                    caller.cb(args)
                 if len(self._emit_queue) > RECURSION_LIMIT:
                     raise RecursionError
             i += 1
@@ -1809,10 +1818,11 @@
     return None  # pragma: no cover
 
 
-_compiled: bool
-
-
-def __getattr__(name: str) -> Any:
-    if name == "_compiled":
-        return hasattr(Signal, "__mypyc_attrs__")
-    raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+# NOTE: this MUST be a module-level assignment rather than a module-level
+# `__getattr__`.  When mypyc compiles a module-level `__getattr__` (supported
+# since mypy 1.20), the native import machinery invokes it while setting up the
+# module's dunder attributes (e.g. `__package__`) *before* the module body has
+# run, which sends `__getattr__` into infinite recursion and segfaults on
+# import.  See:
+# https://github.com/pyapp-kit/psygnal/issues/414
+_compiled: bool = hasattr(Signal, "__mypyc_attrs__")
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/psygnal-0.15.1/src/psygnal/_weak_callback.py 
new/psygnal-0.16.1/src/psygnal/_weak_callback.py
--- old/psygnal-0.15.1/src/psygnal/_weak_callback.py    2026-01-04 
17:24:27.000000000 +0100
+++ new/psygnal-0.16.1/src/psygnal/_weak_callback.py    2026-09-11 
09:53:18.000000000 +0200
@@ -285,6 +285,9 @@
         """Return the original object, or None if dead."""
         raise NotImplementedError()
 
+    def _is_alive(self) -> bool:
+        return self._alive
+
     def __call__(self, *args: Any, **kwds: Any) -> _R:
         func = self.dereference()
         if func is None:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/psygnal-0.15.1/src/psygnal/containers/_evented_list.py 
new/psygnal-0.16.1/src/psygnal/containers/_evented_list.py
--- old/psygnal-0.15.1/src/psygnal/containers/_evented_list.py  2026-01-04 
17:24:27.000000000 +0100
+++ new/psygnal-0.16.1/src/psygnal/containers/_evented_list.py  2026-09-11 
09:53:18.000000000 +0200
@@ -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
@@ -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.
@@ -74,6 +98,38 @@
     """`(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`"""
@@ -118,6 +174,9 @@
 
     events: ListEvents  # pragma: no cover
     _psygnal_group_: ClassVar[str] = "events"
+    # >0 while extend() is bracketing a batch, so insert() doesn't emit its 
own.
+    # (class-level default so instances unpickled from older versions still 
work)
+    _batch_depth: int = 0
 
     def __init__(
         self,
@@ -142,12 +201,66 @@
 
     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 value in values:
+                self.append(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: ...
 
@@ -185,16 +298,34 @@
 
     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
         if isinstance(key, int):
+            if not -len(self) <= key < len(self):
+                raise IndexError("list assignment index out of range")
             yield (self, key if key >= 0 else key + len(self))
         elif isinstance(key, slice):
             yield from ((self, i) for i in range(*key.indices(len(self))))
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/psygnal-0.15.1/tests/containers/test_evented_list.py 
new/psygnal-0.16.1/tests/containers/test_evented_list.py
--- old/psygnal-0.15.1/tests/containers/test_evented_list.py    2026-01-04 
17:24:27.000000000 +0100
+++ new/psygnal-0.16.1/tests/containers/test_evented_list.py    2026-09-11 
09:53:18.000000000 +0200
@@ -1,6 +1,6 @@
 import os
 from copy import copy
-from typing import cast
+from typing import Any, cast
 from unittest.mock import Mock, call
 
 import numpy as np
@@ -8,6 +8,7 @@
 
 from psygnal import EmissionInfo, PathStep, Signal, SignalGroup
 from psygnal.containers import EventedList
+from psygnal.containers._evented_list import _contiguous_runs
 
 
 @pytest.fixture
@@ -22,53 +23,66 @@
     return test_list
 
 
+# per-item events bracketed by the contiguous-block batch_* events
+INSERT = ("batch_inserting", "inserting", "inserted", "batch_inserted")
+REMOVE = ("batch_removing", "removing", "removed", "batch_removed")
+
+
+def _remove_block(n: int) -> tuple[str, ...]:
+    # one contiguous block of N removals:
+    # batch_removing, (removing, removed)*N, batch_removed
+    return ("batch_removing", *(("removing", "removed") * n), "batch_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],),
+            ("batch_inserting", *(("inserting", "inserted") * 3), 
"batch_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],),
+            ("batch_inserting", *(("inserting", "inserted") * 2), 
"batch_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 +92,7 @@
     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,13 +331,22 @@
     assert root == [e_obj]
     e_obj.test.emit("hi")
 
-    assert mock.call_count == 3
+    # batch_inserting + inserting + inserted + batch_inserted + the child event
+    assert mock.call_count == 5
 
     expected = [
+        call(
+            EmissionInfo(root.events.batch_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.batch_inserted, (0, 1, [e_obj]), 
path=(PathStep(index=0),)
+            )
+        ),
+        call(
+            EmissionInfo(
                 e_obj.test, ("hi",), path=(PathStep(index=0), 
PathStep(attr="test"))
             )
         ),
@@ -357,16 +378,26 @@
     e_obj.events.test2.emit("hi")
 
     assert [c[0][0].signal.name for c in mock.call_args_list] == [
+        "batch_inserting",
         "inserting",
         "inserted",
+        "batch_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.batch_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.batch_inserted, (0, 1, [e_obj]), 
path=(PathStep(index=0),)
+            )
+        ),
         call(EmissionInfo(e_obj.events.test2, ("hi",), 
path=(PathStep(index=0),))),
     ]
 
@@ -381,3 +412,252 @@
     l2 = copy(l1)
     l1.append(4)
     assert len(l2) == 3
+
+
[email protected](
+    "indices, expected",
+    [
+        ([], []),  # empty -> no runs
+        ([3], [(3, 4)]),
+        ([1, 2, 3], [(1, 4)]),  # single contiguous block
+        ([3, 1, 2], [(1, 4)]),  # unsorted input
+        ([1, 1, 2], [(1, 3)]),  # duplicates collapsed
+        ([0, 2, 4], [(4, 5), (2, 3), (0, 1)]),  # non-contiguous, highest first
+        ([1, 2, 3, 5, 6], [(5, 7), (1, 4)]),
+    ],
+)
+def test_contiguous_runs(indices: list[int], expected: list[tuple[int, int]]) 
-> None:
+    assert list(_contiguous_runs(indices)) == expected
+
+
+def test_batch_inserted_emits_once_for_batch():
+    """The batch_* signals fire once per contiguous block; per-item N times."""
+    el = EventedList([0, 1, 2])
+    batch_inserting = Mock()
+    batch_inserted = Mock()
+    inserted = Mock()
+    el.events.batch_inserting.connect(batch_inserting)
+    el.events.batch_inserted.connect(batch_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...
+    batch_inserting.assert_called_once_with(3, 6)
+    batch_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
+    batch_inserting.reset_mock()
+    batch_inserted.reset_mock()
+    el.insert(0, 99)
+    batch_inserting.assert_called_once_with(0, 1)
+    batch_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)
+
+
[email protected]("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_batch_removed_emits_per_block():
+    """Contiguous removals emit one block; non-contiguous emit once per 
block."""
+    el = EventedList([0, 1, 2, 3, 4, 5])
+    batch_removing = Mock()
+    batch_removed = Mock()
+    el.events.batch_removing.connect(batch_removing)
+    el.events.batch_removed.connect(batch_removed)
+
+    # contiguous slice -> single block
+    del el[1:4]
+    assert el == [0, 4, 5]
+    batch_removing.assert_called_once_with(1, 4)
+    batch_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]
+    batch_removing.reset_mock()
+    batch_removed.reset_mock()
+    del el[::2]  # indices 0, 2, 4
+    assert el == [1, 3, 5]
+    assert batch_removing.call_args_list == [call(4, 5), call(2, 3), call(0, 
1)]
+    assert batch_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])
+    batch_removing = Mock()
+    batch_removed = Mock()
+    removed = Mock()
+    el.events.batch_removing.connect(batch_removing)
+    el.events.batch_removed.connect(batch_removed)
+    el.events.removed.connect(removed)
+
+    el.clear()
+    assert el == []
+    batch_removing.assert_called_once_with(0, 4)
+    batch_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_batch_signals_drive_qt_style_model():
+    """batch_* (start, stop) ranges wire directly onto 
begin/end{Insert,Remove}Rows."""
+    el = EventedList([0, 1, 2])
+    calls: list[tuple] = []
+
+    el.events.batch_inserting.connect(
+        lambda start, stop: calls.append(("beginInsertRows", start, stop - 1))
+    )
+    el.events.batch_inserted.connect(lambda *_: 
calls.append(("endInsertRows",)))
+    el.events.batch_removing.connect(
+        lambda start, stop: calls.append(("beginRemoveRows", start, stop - 1))
+    )
+    el.events.batch_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",),
+    ]
+
+
+def test_subclass_insert_override_still_called_by_extend():
+    """`extend`/`+=`/`__init__` must keep funneling through the public 
`insert`."""
+
+    class MyList(EventedList):
+        def __init__(self, *args, **kwargs):
+            self.seen: list[tuple[int, Any]] = []
+            super().__init__(*args, **kwargs)
+
+        def insert(self, index: int, value: Any) -> None:
+            self.seen.append((index, value))
+            super().insert(index, value)
+
+    el = MyList([0, 1])  # __init__ extends
+    assert el.seen == [(0, 0), (1, 1)]
+
+    el.extend([2, 3])
+    assert el.seen[-2:] == [(2, 2), (3, 3)]
+
+    el += [4]
+    assert el.seen[-1] == (4, 4)
+    assert el == [0, 1, 2, 3, 4]
+
+
+def test_insert_emits_nothing_if_pre_insert_raises():
+    """A rejected value must not leave an unterminated `batch_inserting` 
behind."""
+
+    class Validated(EventedList):
+        def _pre_insert(self, value: Any) -> Any:
+            if not isinstance(value, int):
+                raise TypeError("ints only")
+            return value
+
+    el = Validated([0, 1])
+    received: list[str] = []
+    el.events.connect(lambda info: received.append(info.signal.name))
+
+    with pytest.raises(TypeError, match="ints only"):
+        el.insert(1, "nope")
+    assert received == []  # not even batch_inserting
+    assert el == [0, 1]
+
+    # and the batch signals stay paired on the success path
+    el.insert(1, 9)
+    assert tuple(received) == INSERT
+    assert el == [0, 9, 1]
+
+
+def test_extend_calls_append_override():
+    """`extend` must keep funneling through `append`, as MutableSequence 
does."""
+
+    class MyList(EventedList):
+        def __init__(self, *args, **kwargs):
+            self.appended: list[Any] = []
+            super().__init__(*args, **kwargs)
+
+        def append(self, value: Any) -> None:
+            self.appended.append(value)
+            super().append(value)
+
+    el = MyList([0])
+    el.extend([1, 2])
+    el += [3]
+    assert el.appended == [0, 1, 2, 3]
+    assert el == [0, 1, 2, 3]
+
+
+def test_extend_appends_despite_reentrant_insert():
+    """A callback inserting mid-extend must not scramble the extended items."""
+    el = EventedList([0, 1])
+
+    def _on_inserted(index: int, value: Any) -> None:
+        if value == "a":
+            el.insert(0, "X")
+
+    el.events.inserted.connect(_on_inserted)
+    el.extend(["a", "b", "c"])
+    assert el == ["X", 0, 1, "a", "b", "c"]
+
+
+def test_mutable_without_instance_batch_depth():
+    """Instances lacking `_batch_depth` (e.g. unpickled from older versions) 
work."""
+    el = EventedList([1, 2])
+    el.__dict__.pop("_batch_depth", None)  # as restored from an older pickle
+    el.append(3)
+    el.extend([4])
+    assert el == [1, 2, 3, 4]
+
+
[email protected]("key", [5, 100, -6, -100])
+def test_delitem_out_of_range(key: int):
+    """Out-of-range deletes raise before emitting (no unpaired 
batch_removing)."""
+    el = EventedList([0, 1, 2, 3, 4])
+    received: list[str] = []
+    el.events.connect(lambda info: received.append(info.signal.name))
+
+    with pytest.raises(IndexError, match="out of range"):
+        del el[key]
+    assert received == []
+    assert el == [0, 1, 2, 3, 4]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/psygnal-0.15.1/tests/test_evented_decorator.py 
new/psygnal-0.16.1/tests/test_evented_decorator.py
--- old/psygnal-0.15.1/tests/test_evented_decorator.py  2026-01-04 
17:24:27.000000000 +0100
+++ new/psygnal-0.16.1/tests/test_evented_decorator.py  2026-09-11 
09:53:18.000000000 +0200
@@ -19,6 +19,7 @@
     testing,
 )
 from psygnal._group import SignalRelay
+from psygnal._group_descriptor import connect_child_events
 
 decorated_or_descriptor = pytest.mark.parametrize(
     "decorator", [True, False], ids=["decorator", "descriptor"]
@@ -266,7 +267,7 @@
     assert "all" in group and isinstance(group["all"], SignalInstance)
     assert not isinstance(group["all"], SignalRelay)
 
-    with pytest.raises(AttributeError):  # it's not writeable
+    with pytest.raises(AttributeError):  # it's not writable
         group.all = SignalRelay({})  # type: ignore
 
     assert group.psygnals_uniform() is False
@@ -504,3 +505,49 @@
     team.events.leader.connect(mock, emit_on_evented_child_events=True)
     team.leader.age = 60
     mock.assert_called_once_with(Person(name="Hannibal", age=60), None)
+
+
[email protected]("enable_manually", [True, False])
[email protected]("connect_children", [True, False])
[email protected]("listen_first", [True, False])
[email protected]("initial_child", [True, False])
+def test_dynamic_child_connection_policy(
+    connect_children: bool,
+    listen_first: bool,
+    initial_child: bool,
+    enable_manually: bool,
+) -> None:
+    @dataclass
+    class Child:
+        value: int = 0
+        events: ClassVar[SignalGroupDescriptor] = SignalGroupDescriptor()
+
+    @dataclass
+    class Parent:
+        child: Child | None = None
+        events: ClassVar[SignalGroupDescriptor] = SignalGroupDescriptor(
+            connect_child_events=connect_children
+        )
+
+    old_child = Child()
+    parent = Parent(old_child if initial_child else None)
+    listener = Mock()
+    if enable_manually:
+        connect_child_events(parent)
+    if listen_first:
+        parent.events.connect(listener)
+    child = Child(value=1)
+    parent.child = child
+    if not listen_first:
+        parent.events.connect(listener)
+    listener.reset_mock()
+    child.value = 2
+    assert listener.call_count == int(connect_children or enable_manually)
+    listener.reset_mock()
+    old_child.value = 3
+    listener.assert_not_called()
+
+    parent.child = None
+    listener.reset_mock()
+    child.value = 4
+    listener.assert_not_called()
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/psygnal-0.15.1/tests/test_group.py 
new/psygnal-0.16.1/tests/test_group.py
--- old/psygnal-0.15.1/tests/test_group.py      2026-01-04 17:24:27.000000000 
+0100
+++ new/psygnal-0.16.1/tests/test_group.py      2026-09-11 09:53:18.000000000 
+0200
@@ -209,6 +209,46 @@
     mock2.assert_not_called()
 
 
+def test_group_disconnect_relay_slot_missing_ok() -> None:
+    """A slot connected to the group can be disconnected with 
`missing_ok=False`.
+
+    See https://github.com/pyapp-kit/psygnal/issues/425
+    """
+    group = MyGroup()
+    mock = Mock()
+
+    group.connect(mock)
+    # should not raise: the slot is connected to the relay, not the child 
signals
+    group.disconnect(mock, missing_ok=False)
+
+    group.sig1.emit(1)
+    mock.assert_not_called()
+
+    # a genuinely-missing slot still raises with missing_ok=False
+    with pytest.raises(ValueError, match="slot is not connected"):
+        group.disconnect(mock, missing_ok=False)
+
+
+def test_group_disconnect_direct_slot_missing_ok() -> None:
+    """A `connect_direct` slot can also be disconnected with 
`missing_ok=False`.
+
+    See https://github.com/pyapp-kit/psygnal/issues/425
+    """
+    group = MyGroup()
+    mock = Mock()
+
+    group.connect_direct(mock)
+    # `connect_direct` puts the slot on the child signals, not the relay
+    group.disconnect(mock, missing_ok=False)
+
+    group.sig1.emit(1)
+    mock.assert_not_called()
+
+    # a genuinely-missing slot still raises with missing_ok=False
+    with pytest.raises(ValueError, match="slot is not connected"):
+        group.disconnect(mock, missing_ok=False)
+
+
 def test_weakref() -> None:
     """Make sure that the group doesn't keep a strong reference to the 
instance."""
     import gc
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/psygnal-0.15.1/tests/test_psygnal.py 
new/psygnal-0.16.1/tests/test_psygnal.py
--- old/psygnal-0.15.1/tests/test_psygnal.py    2026-01-04 17:24:27.000000000 
+0100
+++ new/psygnal-0.16.1/tests/test_psygnal.py    2026-09-11 09:53:18.000000000 
+0200
@@ -1194,6 +1194,22 @@
         assert "NOTE" in str(e.value)
 
 
+def test_emit_loop_error_message_shows_locals(monkeypatch: pytest.MonkeyPatch) 
-> None:
+    """Locals of the failing frame are shown (f_locals isn't a dict on 
3.13+)."""
+    monkeypatch.delenv("PSYGNAL_HIDE_LOCALS", raising=False)
+
+    def cb(v: int) -> None:
+        some_local = "hello"  # noqa: F841
+        raise ValueError("boom")
+
+    sig = SignalInstance((int,))
+    sig.connect(cb)
+    with pytest.raises(EmitLoopError) as e:
+        sig.emit(1)
+    assert "Local variables" in str(e.value)
+    assert "some_local" in str(e.value)
+
+
 def test_description():
     description = "A signal"
 
@@ -1202,3 +1218,92 @@
 
     assert T.sig.description == description
     assert T().sig.description == description
+
+
[email protected]("strategy", ReemissionMode._members())
+def test_signal_emission_disconnect(strategy: ReemissionVal) -> None:
+    """Test that disconnecting during emission doesn't cause problems."""
+    sig = SignalInstance((int,), reemission=strategy)
+    mock1 = Mock()
+    mock2 = Mock()
+    mock3 = Mock()
+
+    def disconnect_cb():
+        mock2()
+        sig.disconnect(disconnect_cb)
+
+    sig.connect(mock1)
+    sig.connect(disconnect_cb)
+    sig.connect(mock3)
+
+    sig.emit(1)
+    mock1.assert_called_once_with(1)
+    mock2.assert_called_once_with()
+    mock3.assert_called_once_with(1)
+
+    mock1.reset_mock()
+    mock2.reset_mock()
+    mock3.reset_mock()
+
+    sig.emit(2)
+    mock1.assert_called_once_with(2)
+    mock2.assert_not_called()
+    mock3.assert_called_once_with(2)
+
+
[email protected]("strategy", ReemissionMode._members())
+def test_object_deleted_during_emission(strategy: ReemissionVal) -> None:
+    """A weakly connected object collected mid-emission is skipped, not called.
+
+    The emit loop iterates a copy of the slots, so a slot removed while the
+    emission is running is still in hand when its turn comes. Calling one whose
+    object has been garbage collected raises ReferenceError.
+    """
+    sig = SignalInstance((int,), reemission=strategy)
+    mock = Mock()
+
+    class Receiver:
+        def method(self, value: int) -> None:
+            mock(value)
+
+    receiver = Receiver()
+    holder = [receiver]
+
+    def delete_receiver(value: int) -> None:
+        holder.clear()
+        gc.collect()
+
+    sig.connect(delete_receiver)
+    sig.connect(receiver.method)
+    del receiver
+
+    sig.emit(1)
+
+    mock.assert_not_called()
+    assert len(sig) == 1
+
+
+def test_threaded_object_deleted_during_emission() -> None:
+    """A dead callback wrapped for thread dispatch is also skipped."""
+    sig = SignalInstance((int,))
+    mock = Mock()
+
+    class Receiver:
+        def method(self, value: int) -> None:
+            mock(value)
+
+    receiver = Receiver()
+    holder = [receiver]
+
+    def delete_receiver(value: int) -> None:
+        holder.clear()
+        gc.collect()
+
+    sig.connect(delete_receiver)
+    sig.connect(receiver.method, thread="current")
+    del receiver
+
+    sig.emit(1)
+
+    mock.assert_not_called()
+    assert len(sig) == 1

Reply via email to