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