Skip to content

Commit 1174aef

Browse files
authored
Merge pull request #26 from nocarryr/py314-compat
Fix Py>=3.14 compat for WeakValueDictionary subclasses
2 parents d053139 + 48ee874 commit 1174aef

6 files changed

Lines changed: 76 additions & 38 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ jobs:
1313
strategy:
1414
fail-fast: false
1515
matrix:
16-
python-version: [3.8, 3.9, "3.10", "3.11", "3.12", "3.13"]
16+
python-version: [3.8, 3.9, "3.10", "3.11", "3.12", "3.13", "3.14"]
1717

1818
steps:
1919
- uses: actions/checkout@v5
@@ -31,6 +31,8 @@ jobs:
3131
run: |
3232
uv run pytest --cov --cov-config=.coveragerc tests pydispatch doc README.md
3333
- name: Test python-dispatch-sphinx
34+
# TODO: Remove 3.14 exclusion when 3.14 support is added to sphinx-plugin
35+
if: ${{ matrix.python-version != '3.14' }}
3436
run: |
3537
uv run pytest --cov --cov-append --cov-config=sphinx-plugin/.coveragerc sphinx-plugin/tests
3638
- name: Upload to Coveralls

.github/workflows/dist-test.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ jobs:
6161
runs-on: ubuntu-latest
6262
strategy:
6363
matrix:
64-
python-version: [3.8, 3.9, "3.10", "3.11", "3.12", "3.13"]
64+
python-version: [3.8, 3.9, "3.10", "3.11", "3.12", "3.13", "3.14"]
6565
dist-type: [sdist, wheel]
6666
fail-fast: false
6767

@@ -103,6 +103,8 @@ jobs:
103103
- name: Test pydispatch distribution
104104
run: py.test tests/
105105
- name: Test pydispatch_sphinx distribution
106+
# TODO: Remove 3.14 exclusion when 3.14 support is added to sphinx-plugin
107+
if: ${{ matrix.python-version != '3.14' }}
106108
run: py.test sphinx-plugin/tests/
107109

108110
deploy:

doc/source/async.rst

Lines changed: 31 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,18 @@ bind_async method
4242
... async def wait_for_event(self):
4343
... await self.event_received.wait()
4444

45-
>>> loop = asyncio.get_event_loop()
46-
>>> emitter = MyEmitter()
47-
>>> listener = MyAsyncListener()
48-
49-
>>> # Pass the event loop as first argument to "bind_async"
50-
>>> emitter.bind_async(loop, on_state=listener.on_emitter_state)
51-
52-
>>> loop.run_until_complete(emitter.trigger())
53-
>>> loop.run_until_complete(listener.wait_for_event())
45+
>>> async def main():
46+
... loop = asyncio.get_running_loop()
47+
... emitter = MyEmitter()
48+
... listener = MyAsyncListener()
49+
...
50+
... # Pass the event loop as first argument to "bind_async"
51+
... emitter.bind_async(loop, on_state=listener.on_emitter_state)
52+
...
53+
... await emitter.trigger()
54+
... await listener.wait_for_event()
55+
56+
>>> asyncio.run(main())
5457
received on_state event
5558

5659
bind (with keyword argument)
@@ -76,15 +79,17 @@ bind (with keyword argument)
7679
... async def wait_for_event(self):
7780
... await self.event_received.wait()
7881

79-
>>> loop = asyncio.get_event_loop()
80-
>>> emitter = MyEmitter()
81-
>>> listener = MyAsyncListener()
82-
83-
>>> # Pass the event loop using __aio_loop__
84-
>>> emitter.bind(on_state=listener.on_emitter_state, __aio_loop__=loop)
85-
86-
>>> loop.run_until_complete(emitter.trigger())
87-
>>> loop.run_until_complete(listener.wait_for_event())
82+
>>> async def main():
83+
... loop = asyncio.get_running_loop()
84+
... emitter = MyEmitter()
85+
... listener = MyAsyncListener()
86+
...
87+
... # Pass the event loop using __aio_loop__
88+
... emitter.bind(on_state=listener.on_emitter_state, __aio_loop__=loop)
89+
... await emitter.trigger()
90+
... await listener.wait_for_event()
91+
92+
>>> asyncio.run(main())
8893
received on_state event
8994

9095
Async (awaitable) Events
@@ -131,11 +136,14 @@ This can also be done with :any:`Property` objects
131136
... break
132137
... return 'done'
133138

134-
>>> loop = asyncio.get_event_loop()
135-
>>> emitter = MyEmitter()
136-
>>> listener = MyAsyncListener()
137-
>>> coros = [emitter.change_values(), listener.wait_for_values(emitter)]
138-
>>> loop.run_until_complete(asyncio.gather(*coros))
139+
>>> async def main():
140+
... loop = asyncio.get_running_loop()
141+
... emitter = MyEmitter()
142+
... listener = MyAsyncListener()
143+
... coros = [emitter.change_values(), listener.wait_for_values(emitter)]
144+
... return await asyncio.gather(*coros)
145+
146+
>>> asyncio.run(main())
139147
0
140148
1
141149
2

pydispatch/aioutils.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,25 @@
1+
import sys
12
import asyncio
23
import threading
3-
from _weakref import ref
4-
from _weakrefset import _IterationGuard
4+
from weakref import ref
5+
from _weakref import _remove_dead_weakref # type: ignore[import]
6+
if sys.version_info < (3, 14):
7+
from _weakrefset import _IterationGuard
8+
else:
9+
# _IterationGuard was removed in Python 3.14. We define a no-op version here
10+
class _IterationGuard:
11+
def __init__(self, obj):
12+
pass
13+
def __enter__(self):
14+
pass
15+
def __exit__(self, *args):
16+
pass
17+
518

619
from pydispatch.utils import (
720
WeakMethodContainer,
821
isfunction,
922
get_method_vars,
10-
_remove_dead_weakref,
1123
)
1224

1325

@@ -192,6 +204,12 @@ def remove(wr, selfref=ref(self)):
192204
_remove_dead_weakref(self.data, wr.key)
193205
self._on_weakref_fin(wr.key)
194206
self._remove = remove
207+
208+
# `_pending_removals` and `_iterating` were removed in Python 3.14.
209+
# To maintain compatibility with earlier versions, we reintroduce them here.
210+
self._pending_removals = []
211+
self._iterating = set()
212+
195213
self.event_loop_map = {}
196214
def add_method(self, loop, callback):
197215
"""Add a coroutine function

pydispatch/dispatch.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -209,14 +209,16 @@ class Foo(Dispatcher):
209209
... async def on_foo_test_event(self, *args, **kwargs):
210210
... self.got_foo_event.set()
211211
212-
>>> loop = asyncio.get_event_loop()
213-
>>> foo = Foo()
214-
>>> bar = Bar()
215-
>>> foo.bind(test_event=bar.on_foo_test_event, __aio_loop__=loop)
216-
>>> fut = asyncio.ensure_future(bar.wait_for_foo())
217-
218-
>>> foo.emit('test_event')
219-
>>> loop.run_until_complete(fut)
212+
>>> async def main():
213+
... loop = asyncio.get_running_loop()
214+
... foo = Foo()
215+
... bar = Bar()
216+
... foo.bind(test_event=bar.on_foo_test_event, __aio_loop__=loop)
217+
... fut = asyncio.create_task(bar.wait_for_foo())
218+
... foo.emit('test_event')
219+
... await fut
220+
221+
>>> asyncio.run(main())
220222
got foo!
221223
222224
This can also be done using :meth:`bind_async`.

pydispatch/utils.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import weakref
2-
from weakref import ref, _remove_dead_weakref
3-
from _weakref import ref
2+
from weakref import ref
3+
from _weakref import _remove_dead_weakref # type: ignore[import]
44
import types
55
import asyncio
66

@@ -114,6 +114,12 @@ def remove(wr, selfref=ref(self)):
114114
_remove_dead_weakref(self.data, wr.key)
115115
self._data_del_callback(wr.key)
116116
self._remove = remove
117+
118+
# `_pending_removals` and `_iterating` were removed in Python 3.14.
119+
# To maintain compatibility with earlier versions, we reintroduce them here.
120+
self._pending_removals = []
121+
self._iterating = set()
122+
117123
self.data = InformativeDict()
118124
self.data.del_callback = self._data_del_callback
119125
def _data_del_callback(self, key):

0 commit comments

Comments
 (0)