vsjetengine 1.6.3__tar.gz → 1.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. {vsjetengine-1.6.3 → vsjetengine-1.7.0}/PKG-INFO +1 -1
  2. {vsjetengine-1.6.3 → vsjetengine-1.7.0}/tests/test_futures.py +169 -0
  3. {vsjetengine-1.6.3 → vsjetengine-1.7.0}/tests/test_loop_adapters.py +84 -11
  4. vsjetengine-1.7.0/vsengine/_version.py +2 -0
  5. {vsjetengine-1.6.3 → vsjetengine-1.7.0}/vsengine/adapters/asyncio.py +15 -9
  6. {vsjetengine-1.6.3 → vsjetengine-1.7.0}/vsengine/adapters/trio.py +50 -12
  7. {vsjetengine-1.6.3 → vsjetengine-1.7.0}/vsengine/futures.py +77 -8
  8. vsjetengine-1.6.3/vsengine/_version.py +0 -2
  9. {vsjetengine-1.6.3 → vsjetengine-1.7.0}/.gitignore +0 -0
  10. {vsjetengine-1.6.3 → vsjetengine-1.7.0}/COPYING +0 -0
  11. {vsjetengine-1.6.3 → vsjetengine-1.7.0}/README.md +0 -0
  12. {vsjetengine-1.6.3 → vsjetengine-1.7.0}/pyproject.toml +0 -0
  13. {vsjetengine-1.6.3 → vsjetengine-1.7.0}/tests/__init__.py +0 -0
  14. {vsjetengine-1.6.3 → vsjetengine-1.7.0}/tests/_testutils.py +0 -0
  15. {vsjetengine-1.6.3 → vsjetengine-1.7.0}/tests/conftest.py +0 -0
  16. {vsjetengine-1.6.3 → vsjetengine-1.7.0}/tests/fixtures/heuristic_examples.json +0 -0
  17. {vsjetengine-1.6.3 → vsjetengine-1.7.0}/tests/fixtures/test.vpy +0 -0
  18. {vsjetengine-1.6.3 → vsjetengine-1.7.0}/tests/test_helpers.py +0 -0
  19. {vsjetengine-1.6.3 → vsjetengine-1.7.0}/tests/test_hospice.py +0 -0
  20. {vsjetengine-1.6.3 → vsjetengine-1.7.0}/tests/test_loops.py +0 -0
  21. {vsjetengine-1.6.3 → vsjetengine-1.7.0}/tests/test_policy.py +0 -0
  22. {vsjetengine-1.6.3 → vsjetengine-1.7.0}/tests/test_policy_store.py +0 -0
  23. {vsjetengine-1.6.3 → vsjetengine-1.7.0}/tests/test_pytest_plugin.py +0 -0
  24. {vsjetengine-1.6.3 → vsjetengine-1.7.0}/tests/test_video.py +0 -0
  25. {vsjetengine-1.6.3 → vsjetengine-1.7.0}/tests/test_vpy.py +0 -0
  26. {vsjetengine-1.6.3 → vsjetengine-1.7.0}/vsengine/__init__.py +0 -0
  27. {vsjetengine-1.6.3 → vsjetengine-1.7.0}/vsengine/_helpers.py +0 -0
  28. {vsjetengine-1.6.3 → vsjetengine-1.7.0}/vsengine/_hospice.py +0 -0
  29. {vsjetengine-1.6.3 → vsjetengine-1.7.0}/vsengine/_nodes.py +0 -0
  30. {vsjetengine-1.6.3 → vsjetengine-1.7.0}/vsengine/adapters/__init__.py +0 -0
  31. {vsjetengine-1.6.3 → vsjetengine-1.7.0}/vsengine/loops.py +0 -0
  32. {vsjetengine-1.6.3 → vsjetengine-1.7.0}/vsengine/policy.py +0 -0
  33. {vsjetengine-1.6.3 → vsjetengine-1.7.0}/vsengine/py.typed +0 -0
  34. {vsjetengine-1.6.3 → vsjetengine-1.7.0}/vsengine/pytest.py +0 -0
  35. {vsjetengine-1.6.3 → vsjetengine-1.7.0}/vsengine/video.py +0 -0
  36. {vsjetengine-1.6.3 → vsjetengine-1.7.0}/vsengine/vpy.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: vsjetengine
3
- Version: 1.6.3
3
+ Version: 1.7.0
4
4
  Summary: An engine for vapoursynth previewers, renderers and script analyis tools.
5
5
  Project-URL: Source Code, https://github.com/Jaded-Encoding-Thaumaturgy/vs-jet-engine
6
6
  Project-URL: Contact, https://discord.gg/XTpc6Fa9eB
@@ -744,6 +744,175 @@ def test_unified_future_from_future_cancellation() -> None:
744
744
  assert raw.cancelled() is True
745
745
 
746
746
 
747
+ def test_unified_future_from_future_identity() -> None:
748
+ uni = UnifiedFuture.resolve(42)
749
+ assert UnifiedFuture.from_future(uni) is uni
750
+
751
+
752
+ def test_unified_future_from_already_cancelled_future() -> None:
753
+ raw = Future[int]()
754
+ raw.cancel()
755
+ uni = UnifiedFuture.from_future(raw)
756
+ assert uni.cancelled() is True
757
+
758
+ loop = asyncio.new_event_loop()
759
+ raw_async = loop.create_future()
760
+ raw_async.cancel()
761
+ loop.close()
762
+ uni_async = UnifiedFuture.from_future(raw_async)
763
+ assert uni_async.cancelled() is True
764
+
765
+
766
+ def test_unified_future_from_closed_asyncio_future() -> None:
767
+ loop = asyncio.new_event_loop()
768
+ raw = loop.create_future()
769
+ raw.set_result(42)
770
+ loop.close()
771
+
772
+ uni = UnifiedFuture.from_future(raw)
773
+
774
+ assert uni.result() == 42
775
+
776
+
777
+ def test_unified_future_from_closed_asyncio_future_incomplete() -> None:
778
+ loop = asyncio.new_event_loop()
779
+ raw = loop.create_future()
780
+ loop.close()
781
+
782
+ uni = UnifiedFuture.from_future(raw)
783
+ assert uni.cancelled() is True
784
+
785
+
786
+ def test_unified_future_from_closed_asyncio_future_exception() -> None:
787
+ loop = asyncio.new_event_loop()
788
+ raw = loop.create_future()
789
+ raw.set_exception(ValueError("closed error"))
790
+ loop.close()
791
+
792
+ uni = UnifiedFuture.from_future(raw)
793
+ assert isinstance(uni.exception(), ValueError)
794
+
795
+
796
+ def test_unified_future_cancellation_propagates_to_closed_asyncio_future() -> None:
797
+ loop = asyncio.new_event_loop()
798
+ raw = loop.create_future()
799
+ uni = UnifiedFuture.from_future(raw)
800
+ loop.close()
801
+
802
+ assert uni.cancel() is True
803
+ assert raw.cancelled() is True
804
+
805
+
806
+ @pytest.mark.asyncio
807
+ async def test_unified_future_cancellation_prevents_asyncio_task_from_running() -> None:
808
+ ran = False
809
+
810
+ async def source() -> int:
811
+ nonlocal ran
812
+ ran = True
813
+ return 42
814
+
815
+ raw = asyncio.create_task(source())
816
+ uni = UnifiedFuture.from_future(raw)
817
+
818
+ assert uni.cancel() is True
819
+ await asyncio.sleep(0)
820
+
821
+ assert raw.cancelled() is True
822
+ assert ran is False
823
+
824
+
825
+ @pytest.mark.asyncio
826
+ async def test_unified_future_from_asyncio_future_exception() -> None:
827
+ set_loop(AsyncIOLoop())
828
+ raw = asyncio.get_running_loop().create_future()
829
+ uni = UnifiedFuture.from_future(raw)
830
+ raw.set_exception(ZeroDivisionError("division by zero"))
831
+
832
+ with pytest.raises(ZeroDivisionError):
833
+ await uni
834
+
835
+
836
+ def test_unified_future_from_asyncio_foreign_thread() -> None:
837
+ loop = asyncio.new_event_loop()
838
+ t = threading.Thread(target=loop.run_forever, daemon=True)
839
+ t.start()
840
+
841
+ try:
842
+ fut = loop.create_future()
843
+ uni = UnifiedFuture[str].from_future(fut)
844
+
845
+ loop.call_soon_threadsafe(fut.set_result, "cross-thread-result")
846
+ assert uni.result(timeout=2.0) == "cross-thread-result"
847
+ finally:
848
+ loop.call_soon_threadsafe(loop.stop)
849
+ t.join()
850
+ loop.close()
851
+
852
+
853
+ def test_unified_future_from_asyncio_foreign_thread_cancellation() -> None:
854
+ loop = asyncio.new_event_loop()
855
+ t = threading.Thread(target=loop.run_forever, daemon=True)
856
+ t.start()
857
+
858
+ try:
859
+ fut = loop.create_future()
860
+ uni = UnifiedFuture[str].from_future(fut)
861
+
862
+ assert uni.cancel() is True
863
+ assert uni.cancelled() is True
864
+
865
+ ev = threading.Event()
866
+ loop.call_soon_threadsafe(ev.set)
867
+ assert ev.wait(timeout=2.0)
868
+
869
+ assert fut.cancelled() is True
870
+ finally:
871
+ loop.call_soon_threadsafe(loop.stop)
872
+ t.join()
873
+ loop.close()
874
+
875
+
876
+ def test_unified_future_from_asyncio_concurrent_close_done(monkeypatch: pytest.MonkeyPatch) -> None:
877
+ loop = asyncio.new_event_loop()
878
+ fut = loop.create_future()
879
+ fut.set_result(123)
880
+
881
+ def fake_call_soon_threadsafe(*args: Any, **kwargs: Any) -> None:
882
+ raise RuntimeError("Event loop is closed")
883
+
884
+ monkeypatch.setattr(loop, "call_soon_threadsafe", fake_call_soon_threadsafe)
885
+
886
+ uni = UnifiedFuture.from_future(fut)
887
+ assert uni.result() == 123
888
+
889
+
890
+ def test_unified_future_from_asyncio_concurrent_close_incomplete(monkeypatch: pytest.MonkeyPatch) -> None:
891
+ loop = asyncio.new_event_loop()
892
+ fut = loop.create_future()
893
+
894
+ def fake_call_soon_threadsafe(*args: Any, **kwargs: Any) -> None:
895
+ raise RuntimeError("Event loop is closed")
896
+
897
+ monkeypatch.setattr(loop, "call_soon_threadsafe", fake_call_soon_threadsafe)
898
+
899
+ uni = UnifiedFuture.from_future(fut)
900
+ assert uni.cancelled() is True
901
+
902
+
903
+ def test_unified_future_from_asyncio_concurrent_close_cancellation(monkeypatch: pytest.MonkeyPatch) -> None:
904
+ loop = asyncio.new_event_loop()
905
+ fut = loop.create_future()
906
+ uni = UnifiedFuture.from_future(fut)
907
+
908
+ def fake_call_soon_threadsafe(*args: Any, **kwargs: Any) -> None:
909
+ raise RuntimeError("Event loop is closed")
910
+
911
+ monkeypatch.setattr(loop, "call_soon_threadsafe", fake_call_soon_threadsafe)
912
+
913
+ assert uni.cancel() is True
914
+
915
+
747
916
  @pytest.mark.asyncio
748
917
  async def test_unified_future_add_loop_callback_cancellation_propagation() -> None:
749
918
  set_loop(AsyncIOLoop())
@@ -9,6 +9,8 @@ from __future__ import annotations
9
9
 
10
10
  import asyncio
11
11
  import contextlib
12
+ import contextvars
13
+ import functools
12
14
  import threading
13
15
  import unittest.mock
14
16
  from collections.abc import Awaitable, Callable, Generator, Iterator
@@ -311,19 +313,90 @@ class TestTrio(AsyncAdapterTest):
311
313
  yield
312
314
 
313
315
 
314
- def test_asyncio_from_thread_without_running_loop() -> None:
315
- """Ensure AsyncIOLoop gracefully executes inline when no asyncio loop is running."""
316
+ def test_asyncio_methods_without_running_loop() -> None:
317
+ """Ensure AsyncIOLoop raises RuntimeError when no asyncio loop is running."""
316
318
  loop = AsyncIOLoop()
317
- fut = loop.from_thread(lambda x: x * 2, 21)
318
- assert fut.done()
319
- assert fut.result() == 42
319
+ with pytest.raises(RuntimeError, match="No running asyncio event loop"):
320
+ loop.from_thread(lambda x: x * 2, 21)
320
321
 
322
+ with pytest.raises(RuntimeError, match="No running asyncio event loop"):
323
+ loop.to_thread(lambda x: x * 2, 21)
321
324
 
322
- def test_trio_from_thread_without_running_token() -> None:
323
- """Ensure TrioEventLoop gracefully executes inline when no Trio token is active."""
325
+ with pytest.raises(RuntimeError, match="No running asyncio event loop"):
326
+ loop.next_cycle()
327
+
328
+
329
+ def test_trio_methods_without_running_token() -> None:
330
+ """Ensure TrioEventLoop raises RuntimeError when no Trio token or nursery is active."""
331
+ loop = TrioEventLoop()
332
+ with pytest.raises(RuntimeError, match="No running Trio event loop"):
333
+ loop.from_thread(lambda x: x + 1, 10)
334
+
335
+ with pytest.raises(RuntimeError, match="Trio nursery is not set"):
336
+ loop.to_thread(lambda x: x + 1, 10)
337
+
338
+ with pytest.raises(RuntimeError, match="No running Trio event loop"):
339
+ loop.next_cycle()
340
+
341
+
342
+ def test_trio_from_thread_contextvars() -> None:
343
+ """Ensure TrioEventLoop propagates contextvars from the calling thread."""
344
+ var: contextvars.ContextVar[str] = contextvars.ContextVar("var", default="default")
345
+
346
+ async def main() -> None:
347
+ async with trio.open_nursery() as nursery:
348
+ loop = TrioEventLoop(nursery)
349
+ results = list[str]()
350
+
351
+ def worker() -> None:
352
+ var.set("custom_val")
353
+ fut = loop.from_thread(var.get)
354
+ results.append(fut.result(timeout=0.5))
355
+
356
+ await trio.to_thread.run_sync(worker)
357
+ assert results == ["custom_val"]
358
+
359
+ trio.run(main)
360
+
361
+
362
+ def test_to_thread_with_partial() -> None:
363
+ """Ensure to_thread handles functools.partial and callables without __name__."""
364
+
365
+ def add(a: int, b: int) -> int:
366
+ return a + b
367
+
368
+ part = functools.partial(add, 10, 20)
369
+
370
+ # Test with TrioEventLoop
371
+ async def trio_main() -> None:
372
+ async with trio.open_nursery() as nursery:
373
+ loop = TrioEventLoop(nursery)
374
+ fut = loop.to_thread(part)
375
+ while not fut.done():
376
+ await trio.sleep(0.01)
377
+ assert fut.result() == 30
378
+
379
+ trio.run(trio_main)
380
+
381
+ # Test with AsyncIOLoop
382
+ async def asyncio_main() -> None:
383
+ loop = AsyncIOLoop()
384
+ fut = loop.to_thread(part)
385
+ while not fut.done():
386
+ await asyncio.sleep(0.01)
387
+ assert fut.result() == 30
388
+
389
+ asyncio.run(asyncio_main())
390
+
391
+
392
+ def test_trio_detach_lifecycle() -> None:
393
+ """Ensure detach cleanly resets token and limiter."""
324
394
  nursery = unittest.mock.MagicMock()
325
- nursery.cancel_scope.cancel_called = False
326
395
  loop = TrioEventLoop(nursery)
327
- fut = loop.from_thread(lambda x: x + 1, 10)
328
- assert fut.done()
329
- assert fut.result() == 11
396
+ loop.token = unittest.mock.MagicMock()
397
+ loop.limiter = unittest.mock.MagicMock()
398
+
399
+ loop.detach()
400
+ nursery.cancel_scope.cancel.assert_called_once()
401
+ assert loop._token is None
402
+ assert loop._limiter is None
@@ -0,0 +1,2 @@
1
+ __version__ = "1.7.0"
2
+ __version_tuple__ = (1, 7, 0)
@@ -20,6 +20,9 @@ class AsyncIOLoop(EventLoop):
20
20
 
21
21
  def __init__(self, loop: asyncio.AbstractEventLoop | None = None) -> None:
22
22
  self._loop = loop
23
+ if self._loop is None:
24
+ with contextlib.suppress(RuntimeError):
25
+ self._loop = asyncio.get_running_loop()
23
26
 
24
27
  @property
25
28
  def loop(self) -> asyncio.AbstractEventLoop | None:
@@ -43,6 +46,9 @@ class AsyncIOLoop(EventLoop):
43
46
  self._loop = None
44
47
 
45
48
  def from_thread[**P, R](self, func: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> Future[R]:
49
+ if (loop := self.loop) is None or loop.is_closed():
50
+ raise RuntimeError("No running asyncio event loop or loop is closed.")
51
+
46
52
  future = Future[R]()
47
53
 
48
54
  def wrap() -> Future[R]:
@@ -58,32 +64,32 @@ class AsyncIOLoop(EventLoop):
58
64
 
59
65
  return future
60
66
 
61
- if (loop := self.loop) is None or loop.is_closed():
62
- return wrap()
63
-
64
67
  loop.call_soon_threadsafe(wrap, context=contextvars.copy_context())
65
68
  return future
66
69
 
67
70
  def to_thread[**P, R](self, func: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> Future[R]:
68
71
  if (loop := self.loop) is None or loop.is_closed():
69
- return super().to_thread(func, *args, **kwargs)
72
+ raise RuntimeError("No running asyncio event loop or loop is closed.")
70
73
 
71
74
  future = Future[R]()
72
75
 
73
76
  async def run() -> None:
77
+ if not future.set_running_or_notify_cancel():
78
+ return
79
+
74
80
  try:
75
- result = await asyncio.to_thread(lambda: func(*args, **kwargs))
81
+ result = await asyncio.to_thread(func, *args, **kwargs)
76
82
  except BaseException as e:
77
83
  future.set_exception(e)
78
84
  else:
79
85
  future.set_result(result)
80
86
 
81
- loop.create_task(run(), name=func.__name__, context=contextvars.copy_context())
87
+ loop.create_task(run(), name=getattr(func, "__name__", None), context=contextvars.copy_context())
82
88
  return future
83
89
 
84
90
  def next_cycle(self) -> Future[None]:
85
91
  if (loop := self.loop) is None or loop.is_closed():
86
- return super().next_cycle()
92
+ raise RuntimeError("No running asyncio event loop or loop is closed.")
87
93
 
88
94
  future = Future[None]()
89
95
  task = asyncio.current_task(loop)
@@ -97,7 +103,7 @@ class AsyncIOLoop(EventLoop):
97
103
  loop.call_soon(continuation)
98
104
  return future
99
105
 
100
- async def await_future[T](self, future: Future[T]) -> T:
106
+ async def await_future[T](self, future: Future[T] | asyncio.Future[T]) -> T:
101
107
  with self.wrap_cancelled():
102
108
  return await asyncio.wrap_future(future)
103
109
 
@@ -106,4 +112,4 @@ class AsyncIOLoop(EventLoop):
106
112
  try:
107
113
  yield
108
114
  except Cancelled:
109
- raise asyncio.CancelledError() from None
115
+ raise asyncio.CancelledError from None
@@ -7,6 +7,7 @@
7
7
  from __future__ import annotations
8
8
 
9
9
  import contextlib
10
+ import contextvars
10
11
  from collections.abc import Callable, Generator
11
12
  from concurrent.futures import Future
12
13
 
@@ -20,10 +21,12 @@ class TrioEventLoop(EventLoop):
20
21
  Bridges vs-engine to Trio.
21
22
  """
22
23
 
23
- def __init__(self, nursery: trio.Nursery, limiter: trio.CapacityLimiter | None = None) -> None:
24
+ def __init__(self, nursery: trio.Nursery | None = None, limiter: trio.CapacityLimiter | None = None) -> None:
24
25
  self.nursery = nursery
25
26
  self._limiter = limiter
26
27
  self._token: trio.lowlevel.TrioToken | None = None
28
+ with contextlib.suppress(RuntimeError):
29
+ self._token = trio.lowlevel.current_trio_token()
27
30
 
28
31
  @property
29
32
  def limiter(self) -> trio.CapacityLimiter | None:
@@ -56,9 +59,16 @@ class TrioEventLoop(EventLoop):
56
59
  self._token = trio.lowlevel.current_trio_token()
57
60
 
58
61
  def detach(self) -> None:
59
- self.nursery.cancel_scope.cancel()
62
+ if self.nursery is not None:
63
+ with contextlib.suppress(RuntimeError):
64
+ self.nursery.cancel_scope.cancel()
65
+ self._token = None
66
+ self._limiter = None
60
67
 
61
68
  def from_thread[**P, R](self, func: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> Future[R]:
69
+ if (token := self.token) is None:
70
+ raise RuntimeError("No running Trio event loop or token is inactive.")
71
+
62
72
  future = Future[R]()
63
73
 
64
74
  def executor() -> Future[R]:
@@ -73,37 +83,58 @@ class TrioEventLoop(EventLoop):
73
83
  future.set_result(result)
74
84
  return future
75
85
 
76
- if token := self.token:
77
- token.run_sync_soon(executor)
78
- return future
86
+ try:
87
+ token.run_sync_soon(contextvars.copy_context().run, executor)
88
+ except (trio.RunFinishedError, trio.ClosedResourceError) as e:
89
+ self._token = None
90
+ raise RuntimeError("Trio event loop has already finished or is closed.") from e
79
91
 
80
- return executor()
92
+ return future
81
93
 
82
94
  def to_thread[**P, R](self, func: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> Future[R]:
95
+ if self.nursery is None:
96
+ raise RuntimeError("Trio nursery is not set.")
83
97
  if self.nursery.cancel_scope.cancel_called:
84
- return super().to_thread(func, *args, **kwargs)
98
+ raise RuntimeError("Trio nursery has been cancelled.")
85
99
 
86
100
  future = Future[R]()
87
101
 
88
102
  async def run() -> None:
89
103
  def executor() -> None:
104
+ if not future.set_running_or_notify_cancel():
105
+ return
106
+
90
107
  try:
91
108
  result = func(*args, **kwargs)
92
- future.set_result(result)
93
109
  except BaseException as e:
94
110
  future.set_exception(e)
111
+ else:
112
+ future.set_result(result)
95
113
 
96
- await trio.to_thread.run_sync(executor, limiter=self.limiter)
114
+ try:
115
+ await trio.to_thread.run_sync(executor, limiter=self.limiter)
116
+ except BaseException as e:
117
+ if not future.done():
118
+ future.set_exception(Cancelled() if isinstance(e, trio.Cancelled) else e)
119
+ raise
120
+
121
+ try:
122
+ self.nursery.start_soon(run, name=getattr(func, "__name__", None))
123
+ except RuntimeError as e:
124
+ raise RuntimeError("Trio nursery is closed or cannot start tasks.") from e
97
125
 
98
- self.nursery.start_soon(run, name=func.__name__)
99
126
  return future
100
127
 
101
128
  def next_cycle(self) -> Future[None]:
102
- scope = trio.CancelScope()
129
+ if self.token is None:
130
+ raise RuntimeError("No running Trio event loop or token is inactive.")
131
+ if self.nursery is None or self.nursery.cancel_scope.cancel_called:
132
+ raise RuntimeError("Trio nursery is not set or has been cancelled.")
133
+
103
134
  future = Future[None]()
104
135
 
105
136
  def continuation() -> None:
106
- if scope.cancel_called:
137
+ if self.nursery is not None and self.nursery.cancel_scope.cancel_called:
107
138
  future.set_exception(Cancelled())
108
139
  else:
109
140
  future.set_result(None)
@@ -112,6 +143,13 @@ class TrioEventLoop(EventLoop):
112
143
  return future
113
144
 
114
145
  async def await_future[T](self, future: Future[T]) -> T:
146
+ if future.done():
147
+ try:
148
+ return future.result()
149
+ except BaseException:
150
+ with self.wrap_cancelled():
151
+ raise
152
+
115
153
  event = trio.Event()
116
154
 
117
155
  def _when_done(_: Future[T]) -> None:
@@ -5,10 +5,12 @@
5
5
  # SPDX-License-Identifier: EUPL-1.2
6
6
  from __future__ import annotations
7
7
 
8
+ import asyncio
9
+ import contextvars
8
10
  import traceback
9
11
  from collections.abc import AsyncIterator, Awaitable, Callable, Generator, Iterator
10
- from concurrent.futures import Future
11
- from contextlib import AbstractAsyncContextManager, AbstractContextManager
12
+ from concurrent.futures import CancelledError, Future
13
+ from contextlib import AbstractAsyncContextManager, AbstractContextManager, suppress
12
14
  from functools import wraps
13
15
  from inspect import isgeneratorfunction
14
16
  from types import TracebackType
@@ -63,7 +65,7 @@ class UnifiedFuture[T](Future[T], AbstractContextManager[Any], AbstractAsyncCont
63
65
  return cls.from_future(future)
64
66
 
65
67
  @classmethod
66
- def from_future[S](cls: type[UnifiedFutureLike[S]], future: Future[S]) -> UnifiedFutureLike[S]:
68
+ def from_future[S](cls: type[UnifiedFutureLike[S]], future: Future[S] | asyncio.Future[S]) -> UnifiedFutureLike[S]:
67
69
  """
68
70
  Wrap an existing `Future` as a `UnifiedFuture`.
69
71
 
@@ -77,17 +79,47 @@ class UnifiedFuture[T](Future[T], AbstractContextManager[Any], AbstractAsyncCont
77
79
 
78
80
  result = cls()
79
81
 
80
- def _receive(fn: Future[S]) -> None:
82
+ def receive(_: Future[S] | asyncio.Future[S]) -> None:
83
+ if result.done():
84
+ return
81
85
  if future.cancelled():
82
86
  result.cancel()
83
87
  return
84
- if (exc := future.exception()) is not None:
88
+ try:
89
+ exc = future.exception()
90
+ except (CancelledError, asyncio.CancelledError):
91
+ # Catch any cancellation race condition
92
+ result.cancel()
93
+ return
94
+
95
+ if exc is not None:
85
96
  result.set_exception(exc)
86
- elif not result.cancelled():
97
+ else:
87
98
  result.set_result(future.result())
88
99
 
89
- future.add_done_callback(_receive)
90
- result.add_done_callback(lambda f: future.cancel() if f.cancelled() else None)
100
+ # Safely propagate cancellation back to the original future
101
+ def on_result_cancelled(f: Future[S]) -> None:
102
+ if not f.cancelled() or future.done():
103
+ return
104
+
105
+ if isinstance(future, asyncio.Future):
106
+ cls._dispatch_to_loop(future, future.cancel)
107
+ else:
108
+ future.cancel()
109
+
110
+ result.add_done_callback(on_result_cancelled)
111
+
112
+ # Safely register the completion callback
113
+ if isinstance(future, asyncio.Future):
114
+ cls._dispatch_to_loop(
115
+ future,
116
+ future.add_done_callback,
117
+ receive,
118
+ on_closed_done=receive,
119
+ on_closed_incomplete=result.cancel,
120
+ )
121
+ else:
122
+ future.add_done_callback(receive)
91
123
  return result
92
124
 
93
125
  @classmethod
@@ -361,6 +393,43 @@ class UnifiedFuture[T](Future[T], AbstractContextManager[Any], AbstractAsyncCont
361
393
 
362
394
  raise NotImplementedError("(async) with is not implemented for this object")
363
395
 
396
+ @staticmethod
397
+ def _dispatch_to_loop[*Ts](
398
+ fut: asyncio.Future[Any],
399
+ action: Callable[[*Ts], Any],
400
+ *args: *Ts,
401
+ on_closed_done: Callable[[asyncio.Future[Any]], Any] | None = None,
402
+ on_closed_incomplete: Callable[[], Any] | None = None,
403
+ ) -> None:
404
+ f_loop = fut.get_loop()
405
+ try:
406
+ current_loop = asyncio.get_running_loop()
407
+ except RuntimeError:
408
+ current_loop = None
409
+
410
+ if current_loop is f_loop:
411
+ action(*args)
412
+ elif f_loop.is_closed():
413
+ if fut.done() and on_closed_done:
414
+ on_closed_done(fut)
415
+ elif on_closed_incomplete:
416
+ on_closed_incomplete()
417
+ else:
418
+ with suppress(RuntimeError):
419
+ action(*args)
420
+ else:
421
+ try:
422
+ f_loop.call_soon_threadsafe(action, *args, context=contextvars.copy_context())
423
+ except RuntimeError:
424
+ # Loop closed concurrently
425
+ if fut.done() and on_closed_done is not None:
426
+ on_closed_done(fut)
427
+ elif on_closed_incomplete is not None:
428
+ on_closed_incomplete()
429
+ else:
430
+ with suppress(RuntimeError):
431
+ action(*args)
432
+
364
433
  def _link_cancellation(
365
434
  self,
366
435
  target: Future[Any],
@@ -1,2 +0,0 @@
1
- __version__ = "1.6.3"
2
- __version_tuple__ = (1, 6, 3)
File without changes
File without changes
File without changes
File without changes
File without changes