From d6090add23ca93f734fed2fe0fc95ccf9ac10203 Mon Sep 17 00:00:00 2001 From: nocarryr Date: Wed, 17 Dec 2025 17:49:51 -0600 Subject: [PATCH 1/4] Fix Py>=3.14 compat for WeakValueDictionary subclasses Changes made in python/cpython#125325 Fixes #24 --- pydispatch/aioutils.py | 24 +++++++++++++++++++++--- pydispatch/utils.py | 10 ++++++++-- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/pydispatch/aioutils.py b/pydispatch/aioutils.py index 8620154..2a5040d 100644 --- a/pydispatch/aioutils.py +++ b/pydispatch/aioutils.py @@ -1,13 +1,25 @@ +import sys import asyncio import threading -from _weakref import ref -from _weakrefset import _IterationGuard +from weakref import ref +from _weakref import _remove_dead_weakref # type: ignore[import] +if sys.version_info < (3, 14): + from _weakrefset import _IterationGuard +else: + # _IterationGuard was removed in Python 3.14. We define a no-op version here + class _IterationGuard: + def __init__(self, obj): + pass + def __enter__(self): + pass + def __exit__(self, *args): + pass + from pydispatch.utils import ( WeakMethodContainer, isfunction, get_method_vars, - _remove_dead_weakref, ) @@ -192,6 +204,12 @@ def remove(wr, selfref=ref(self)): _remove_dead_weakref(self.data, wr.key) self._on_weakref_fin(wr.key) self._remove = remove + + # `_pending_removals` and `_iterating` were removed in Python 3.14. + # To maintain compatibility with earlier versions, we reintroduce them here. + self._pending_removals = [] + self._iterating = set() + self.event_loop_map = {} def add_method(self, loop, callback): """Add a coroutine function diff --git a/pydispatch/utils.py b/pydispatch/utils.py index 2668f4b..066f7fd 100644 --- a/pydispatch/utils.py +++ b/pydispatch/utils.py @@ -1,6 +1,6 @@ import weakref -from weakref import ref, _remove_dead_weakref -from _weakref import ref +from weakref import ref +from _weakref import _remove_dead_weakref # type: ignore[import] import types import asyncio @@ -114,6 +114,12 @@ def remove(wr, selfref=ref(self)): _remove_dead_weakref(self.data, wr.key) self._data_del_callback(wr.key) self._remove = remove + + # `_pending_removals` and `_iterating` were removed in Python 3.14. + # To maintain compatibility with earlier versions, we reintroduce them here. + self._pending_removals = [] + self._iterating = set() + self.data = InformativeDict() self.data.del_callback = self._data_del_callback def _data_del_callback(self, key): From 209c0555e1ae18dfbe9dfa05b8b98f39daea5297 Mon Sep 17 00:00:00 2001 From: nocarryr Date: Wed, 17 Dec 2025 17:56:11 -0600 Subject: [PATCH 2/4] Add Python 3.14 to test matrix --- .github/workflows/ci.yml | 2 +- .github/workflows/dist-test.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e2d9e2..48c813d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: [3.8, 3.9, "3.10", "3.11", "3.12", "3.13"] + python-version: [3.8, 3.9, "3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v5 diff --git a/.github/workflows/dist-test.yml b/.github/workflows/dist-test.yml index abf3046..99a321f 100644 --- a/.github/workflows/dist-test.yml +++ b/.github/workflows/dist-test.yml @@ -61,7 +61,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.8, 3.9, "3.10", "3.11", "3.12", "3.13"] + python-version: [3.8, 3.9, "3.10", "3.11", "3.12", "3.13", "3.14"] dist-type: [sdist, wheel] fail-fast: false From ba9d084b924e63863345504308368c3b12c8bf70 Mon Sep 17 00:00:00 2001 From: nocarryr Date: Wed, 17 Dec 2025 18:13:53 -0600 Subject: [PATCH 3/4] Correct doctest examples --- doc/source/async.rst | 54 ++++++++++++++++++++++++------------------ pydispatch/dispatch.py | 18 +++++++------- 2 files changed, 41 insertions(+), 31 deletions(-) diff --git a/doc/source/async.rst b/doc/source/async.rst index 7a510b7..d39ede5 100644 --- a/doc/source/async.rst +++ b/doc/source/async.rst @@ -42,15 +42,18 @@ bind_async method ... async def wait_for_event(self): ... await self.event_received.wait() - >>> loop = asyncio.get_event_loop() - >>> emitter = MyEmitter() - >>> listener = MyAsyncListener() - - >>> # Pass the event loop as first argument to "bind_async" - >>> emitter.bind_async(loop, on_state=listener.on_emitter_state) - - >>> loop.run_until_complete(emitter.trigger()) - >>> loop.run_until_complete(listener.wait_for_event()) + >>> async def main(): + ... loop = asyncio.get_running_loop() + ... emitter = MyEmitter() + ... listener = MyAsyncListener() + ... + ... # Pass the event loop as first argument to "bind_async" + ... emitter.bind_async(loop, on_state=listener.on_emitter_state) + ... + ... await emitter.trigger() + ... await listener.wait_for_event() + + >>> asyncio.run(main()) received on_state event bind (with keyword argument) @@ -76,15 +79,17 @@ bind (with keyword argument) ... async def wait_for_event(self): ... await self.event_received.wait() - >>> loop = asyncio.get_event_loop() - >>> emitter = MyEmitter() - >>> listener = MyAsyncListener() - - >>> # Pass the event loop using __aio_loop__ - >>> emitter.bind(on_state=listener.on_emitter_state, __aio_loop__=loop) - - >>> loop.run_until_complete(emitter.trigger()) - >>> loop.run_until_complete(listener.wait_for_event()) + >>> async def main(): + ... loop = asyncio.get_running_loop() + ... emitter = MyEmitter() + ... listener = MyAsyncListener() + ... + ... # Pass the event loop using __aio_loop__ + ... emitter.bind(on_state=listener.on_emitter_state, __aio_loop__=loop) + ... await emitter.trigger() + ... await listener.wait_for_event() + + >>> asyncio.run(main()) received on_state event Async (awaitable) Events @@ -131,11 +136,14 @@ This can also be done with :any:`Property` objects ... break ... return 'done' - >>> loop = asyncio.get_event_loop() - >>> emitter = MyEmitter() - >>> listener = MyAsyncListener() - >>> coros = [emitter.change_values(), listener.wait_for_values(emitter)] - >>> loop.run_until_complete(asyncio.gather(*coros)) + >>> async def main(): + ... loop = asyncio.get_running_loop() + ... emitter = MyEmitter() + ... listener = MyAsyncListener() + ... coros = [emitter.change_values(), listener.wait_for_values(emitter)] + ... return await asyncio.gather(*coros) + + >>> asyncio.run(main()) 0 1 2 diff --git a/pydispatch/dispatch.py b/pydispatch/dispatch.py index df5c95b..d448dc2 100644 --- a/pydispatch/dispatch.py +++ b/pydispatch/dispatch.py @@ -209,14 +209,16 @@ class Foo(Dispatcher): ... async def on_foo_test_event(self, *args, **kwargs): ... self.got_foo_event.set() - >>> loop = asyncio.get_event_loop() - >>> foo = Foo() - >>> bar = Bar() - >>> foo.bind(test_event=bar.on_foo_test_event, __aio_loop__=loop) - >>> fut = asyncio.ensure_future(bar.wait_for_foo()) - - >>> foo.emit('test_event') - >>> loop.run_until_complete(fut) + >>> async def main(): + ... loop = asyncio.get_running_loop() + ... foo = Foo() + ... bar = Bar() + ... foo.bind(test_event=bar.on_foo_test_event, __aio_loop__=loop) + ... fut = asyncio.create_task(bar.wait_for_foo()) + ... foo.emit('test_event') + ... await fut + + >>> asyncio.run(main()) got foo! This can also be done using :meth:`bind_async`. From 48ee87429e249cfff2a5d328c975e0a04853ac42 Mon Sep 17 00:00:00 2001 From: nocarryr Date: Wed, 17 Dec 2025 18:22:48 -0600 Subject: [PATCH 4/4] Exclude sphinx plugin tests in 3.14 (for now) --- .github/workflows/ci.yml | 2 ++ .github/workflows/dist-test.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48c813d..ead3226 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,8 @@ jobs: run: | uv run pytest --cov --cov-config=.coveragerc tests pydispatch doc README.md - name: Test python-dispatch-sphinx + # TODO: Remove 3.14 exclusion when 3.14 support is added to sphinx-plugin + if: ${{ matrix.python-version != '3.14' }} run: | uv run pytest --cov --cov-append --cov-config=sphinx-plugin/.coveragerc sphinx-plugin/tests - name: Upload to Coveralls diff --git a/.github/workflows/dist-test.yml b/.github/workflows/dist-test.yml index 99a321f..2c9c537 100644 --- a/.github/workflows/dist-test.yml +++ b/.github/workflows/dist-test.yml @@ -103,6 +103,8 @@ jobs: - name: Test pydispatch distribution run: py.test tests/ - name: Test pydispatch_sphinx distribution + # TODO: Remove 3.14 exclusion when 3.14 support is added to sphinx-plugin + if: ${{ matrix.python-version != '3.14' }} run: py.test sphinx-plugin/tests/ deploy: