vsjetengine 1.6.2__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.2 → vsjetengine-1.7.0}/PKG-INFO +1 -1
  2. {vsjetengine-1.6.2 → vsjetengine-1.7.0}/tests/test_futures.py +241 -0
  3. {vsjetengine-1.6.2 → 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.2 → vsjetengine-1.7.0}/vsengine/adapters/asyncio.py +15 -9
  6. {vsjetengine-1.6.2 → vsjetengine-1.7.0}/vsengine/adapters/trio.py +50 -12
  7. {vsjetengine-1.6.2 → vsjetengine-1.7.0}/vsengine/futures.py +144 -43
  8. vsjetengine-1.6.2/vsengine/_version.py +0 -2
  9. {vsjetengine-1.6.2 → vsjetengine-1.7.0}/.gitignore +0 -0
  10. {vsjetengine-1.6.2 → vsjetengine-1.7.0}/COPYING +0 -0
  11. {vsjetengine-1.6.2 → vsjetengine-1.7.0}/README.md +0 -0
  12. {vsjetengine-1.6.2 → vsjetengine-1.7.0}/pyproject.toml +0 -0
  13. {vsjetengine-1.6.2 → vsjetengine-1.7.0}/tests/__init__.py +0 -0
  14. {vsjetengine-1.6.2 → vsjetengine-1.7.0}/tests/_testutils.py +0 -0
  15. {vsjetengine-1.6.2 → vsjetengine-1.7.0}/tests/conftest.py +0 -0
  16. {vsjetengine-1.6.2 → vsjetengine-1.7.0}/tests/fixtures/heuristic_examples.json +0 -0
  17. {vsjetengine-1.6.2 → vsjetengine-1.7.0}/tests/fixtures/test.vpy +0 -0
  18. {vsjetengine-1.6.2 → vsjetengine-1.7.0}/tests/test_helpers.py +0 -0
  19. {vsjetengine-1.6.2 → vsjetengine-1.7.0}/tests/test_hospice.py +0 -0
  20. {vsjetengine-1.6.2 → vsjetengine-1.7.0}/tests/test_loops.py +0 -0
  21. {vsjetengine-1.6.2 → vsjetengine-1.7.0}/tests/test_policy.py +0 -0
  22. {vsjetengine-1.6.2 → vsjetengine-1.7.0}/tests/test_policy_store.py +0 -0
  23. {vsjetengine-1.6.2 → vsjetengine-1.7.0}/tests/test_pytest_plugin.py +0 -0
  24. {vsjetengine-1.6.2 → vsjetengine-1.7.0}/tests/test_video.py +0 -0
  25. {vsjetengine-1.6.2 → vsjetengine-1.7.0}/tests/test_vpy.py +0 -0
  26. {vsjetengine-1.6.2 → vsjetengine-1.7.0}/vsengine/__init__.py +0 -0
  27. {vsjetengine-1.6.2 → vsjetengine-1.7.0}/vsengine/_helpers.py +0 -0
  28. {vsjetengine-1.6.2 → vsjetengine-1.7.0}/vsengine/_hospice.py +0 -0
  29. {vsjetengine-1.6.2 → vsjetengine-1.7.0}/vsengine/_nodes.py +0 -0
  30. {vsjetengine-1.6.2 → vsjetengine-1.7.0}/vsengine/adapters/__init__.py +0 -0
  31. {vsjetengine-1.6.2 → vsjetengine-1.7.0}/vsengine/loops.py +0 -0
  32. {vsjetengine-1.6.2 → vsjetengine-1.7.0}/vsengine/policy.py +0 -0
  33. {vsjetengine-1.6.2 → vsjetengine-1.7.0}/vsengine/py.typed +0 -0
  34. {vsjetengine-1.6.2 → vsjetengine-1.7.0}/vsengine/pytest.py +0 -0
  35. {vsjetengine-1.6.2 → vsjetengine-1.7.0}/vsengine/video.py +0 -0
  36. {vsjetengine-1.6.2 → 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.2
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
@@ -5,6 +5,7 @@
5
5
  # SPDX-License-Identifier: EUPL-1.2
6
6
  """Tests for the unified future system."""
7
7
 
8
+ import asyncio
8
9
  import contextlib
9
10
  import threading
10
11
  from collections.abc import AsyncGenerator, Generator, Iterator
@@ -741,3 +742,243 @@ def test_unified_future_from_future_cancellation() -> None:
741
742
  assert uni.cancel() is True
742
743
  assert uni.cancelled() is True
743
744
  assert raw.cancelled() is True
745
+
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
+
916
+ @pytest.mark.asyncio
917
+ async def test_unified_future_add_loop_callback_cancellation_propagation() -> None:
918
+ set_loop(AsyncIOLoop())
919
+ raw = UnifiedFuture[int]()
920
+ chained = raw.add_loop_callback(lambda f: None)
921
+
922
+ assert chained.cancel() is True
923
+ assert chained.cancelled() is True
924
+ assert raw.cancelled() is True
925
+
926
+
927
+ @pytest.mark.asyncio
928
+ async def test_unified_future_add_loop_callback_custom_on_cancel() -> None:
929
+ set_loop(AsyncIOLoop())
930
+ cancelled_calls = list[threading.Thread]()
931
+ loop_thread = threading.current_thread()
932
+ raw = UnifiedFuture[int]()
933
+ chained = raw.add_loop_callback(
934
+ lambda f: None,
935
+ cancel_cb=lambda: cancelled_calls.append(threading.current_thread()),
936
+ )
937
+
938
+ assert chained.cancel() is True
939
+ assert chained.cancelled() is True
940
+ await asyncio.sleep(0.01)
941
+ assert cancelled_calls == [loop_thread]
942
+ assert raw.cancelled() is False
943
+
944
+
945
+ @pytest.mark.asyncio
946
+ async def test_unified_future_then_custom_on_cancel_on_loop() -> None:
947
+ set_loop(AsyncIOLoop())
948
+ cancelled_calls = list[threading.Thread]()
949
+ loop_thread = threading.current_thread()
950
+ raw = UnifiedFuture[int]()
951
+ chained = raw.then(
952
+ lambda x: x * 2,
953
+ cancel_cb=lambda: cancelled_calls.append(threading.current_thread()),
954
+ on_loop=True,
955
+ )
956
+
957
+ # Cancel from a background thread
958
+ t = threading.Thread(target=chained.cancel)
959
+ t.start()
960
+ t.join()
961
+
962
+ await asyncio.sleep(0.01)
963
+ assert chained.cancelled() is True
964
+ assert cancelled_calls == [loop_thread]
965
+ assert raw.cancelled() is False
966
+
967
+
968
+ @pytest.mark.asyncio
969
+ async def test_unified_future_add_loop_callback_parent_cancelled() -> None:
970
+ set_loop(AsyncIOLoop())
971
+ raw = UnifiedFuture[int]()
972
+ called = list[bool]()
973
+
974
+ def cb(f: Future[int]) -> None:
975
+ called.append(f.cancelled())
976
+
977
+ chained = raw.add_loop_callback(cb)
978
+ raw.cancel()
979
+
980
+ with pytest.raises(asyncio.CancelledError):
981
+ await chained
982
+
983
+ assert chained.cancelled() is True
984
+ assert called == [True]
@@ -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
@@ -127,7 +159,11 @@ class UnifiedFuture[T](Future[T], AbstractContextManager[Any], AbstractAsyncCont
127
159
  # The done_callback should inherit the environment of the current call.
128
160
  super().add_done_callback(keep_environment(fn))
129
161
 
130
- def add_loop_callback(self, func: Callable[[Future[T]], Any]) -> UnifiedFuture[T]:
162
+ def add_loop_callback(
163
+ self,
164
+ func: Callable[[Future[T]], Any],
165
+ cancel_cb: Callable[[], None] | None = None,
166
+ ) -> UnifiedFuture[T]:
131
167
  """
132
168
  Register a callback that is guaranteed to run on the event-loop thread.
133
169
 
@@ -135,25 +171,28 @@ class UnifiedFuture[T](Future[T], AbstractContextManager[Any], AbstractAsyncCont
135
171
  this method marshals `func` back to the main event loop via `EventLoop.from_thread`.
136
172
 
137
173
  :param func: A callable that receives the completed future.
174
+ :param cancel_cb: Optional callback invoked when the returned future is cancelled.
175
+ Defaults to cancelling the parent future.
138
176
  :return: A UnifiedFuture that resolves to the original future's result once the callback completes
139
177
  on the event loop.
140
178
  """
141
179
  result = UnifiedFuture[T]()
142
180
 
143
- def _wrapper(future: Future[T]) -> None:
144
- loop_fut = get_loop().from_thread(func, future)
145
-
146
- def _forward(lf: Future[Any]) -> None:
147
- if (exc := lf.exception()) is not None:
148
- result.set_exception(exc)
149
- elif (orig_exc := future.exception()) is not None:
150
- result.set_exception(orig_exc)
151
- else:
152
- result.set_result(future.result())
181
+ def forward_parent(_: Future[Any]) -> None:
182
+ if self.cancelled():
183
+ result.cancel()
184
+ elif (orig_exc := self.exception()) is not None:
185
+ result.set_exception(orig_exc)
186
+ else:
187
+ result.set_result(self.result())
153
188
 
154
- loop_fut.add_done_callback(_forward)
189
+ def wrapper(future: Future[T]) -> None:
190
+ if result.cancelled():
191
+ return
192
+ self._forward_loop_task(get_loop().from_thread(func, future), result, forward_parent)
155
193
 
156
- self.add_done_callback(_wrapper)
194
+ self.add_done_callback(wrapper)
195
+ self._link_cancellation(result, cancel_cb, on_loop=True)
157
196
  return result
158
197
 
159
198
  # Manipulating futures
@@ -227,23 +266,11 @@ class UnifiedFuture[T](Future[T], AbstractContextManager[Any], AbstractAsyncCont
227
266
  """
228
267
  result = UnifiedFuture[Any]()
229
268
 
230
- def _run_cb(cb: Callable[[Any], Any], v: T | BaseException) -> None:
269
+ def run_cb(cb: Callable[[Any], Any], v: T | BaseException) -> None:
231
270
  if result.cancelled():
232
271
  return
233
272
  if on_loop:
234
- loop_fut = get_loop().from_thread(cb, v)
235
-
236
- def _forward(lf: Future[Any]) -> None:
237
- if result.cancelled():
238
- return
239
- if lf.cancelled():
240
- result.cancel()
241
- elif (exc := lf.exception()) is not None:
242
- result.set_exception(exc)
243
- else:
244
- result.set_result(lf.result())
245
-
246
- loop_fut.add_done_callback(_forward)
273
+ self._forward_loop_task(get_loop().from_thread(cb, v), result)
247
274
  else:
248
275
  try:
249
276
  r = cb(v)
@@ -252,26 +279,23 @@ class UnifiedFuture[T](Future[T], AbstractContextManager[Any], AbstractAsyncCont
252
279
  else:
253
280
  result.set_result(r)
254
281
 
255
- def _done(fn: Future[T]) -> None:
282
+ def done(_: Future[T]) -> None:
256
283
  if self.cancelled():
257
284
  result.cancel()
258
285
  return
259
286
  if (exc := self.exception()) is not None:
260
287
  if err_cb is not None:
261
- _run_cb(err_cb, exc)
288
+ run_cb(err_cb, exc)
262
289
  else:
263
290
  result.set_exception(exc)
264
291
  elif not result.cancelled():
265
292
  if success_cb is not None:
266
- _run_cb(success_cb, self.result())
293
+ run_cb(success_cb, self.result())
267
294
  else:
268
295
  result.set_result(self.result())
269
296
 
270
- self.add_done_callback(_done)
271
- if cancel_cb is not None:
272
- result.add_done_callback(lambda f: cancel_cb() if f.cancelled() else None)
273
- else:
274
- result.add_done_callback(lambda f: self.cancel() if f.cancelled() else None)
297
+ self.add_done_callback(done)
298
+ self._link_cancellation(result, cancel_cb, on_loop=on_loop)
275
299
  return result
276
300
 
277
301
  def map[V](
@@ -369,6 +393,83 @@ class UnifiedFuture[T](Future[T], AbstractContextManager[Any], AbstractAsyncCont
369
393
 
370
394
  raise NotImplementedError("(async) with is not implemented for this object")
371
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
+
433
+ def _link_cancellation(
434
+ self,
435
+ target: Future[Any],
436
+ cancel_cb: Callable[[], None] | None = None,
437
+ *,
438
+ on_loop: bool = False,
439
+ ) -> None:
440
+ if cancel_cb is not None:
441
+
442
+ def on_cancel(f: Future[Any]) -> None:
443
+ if f.cancelled():
444
+ if on_loop:
445
+ get_loop().from_thread(cancel_cb)
446
+ else:
447
+ cancel_cb()
448
+
449
+ target.add_done_callback(on_cancel)
450
+ else:
451
+ target.add_done_callback(lambda f: self.cancel() if f.cancelled() else None)
452
+
453
+ @staticmethod
454
+ def _forward_loop_task(
455
+ loop_fut: Future[Any],
456
+ target: UnifiedFuture[Any],
457
+ on_done: Callable[[Future[Any]], None] | None = None,
458
+ ) -> None:
459
+ def forward(lf: Future[Any]) -> None:
460
+ if target.cancelled():
461
+ return
462
+ if lf.cancelled():
463
+ target.cancel()
464
+ elif (exc := lf.exception()) is not None:
465
+ target.set_exception(exc)
466
+ elif on_done is not None:
467
+ on_done(lf)
468
+ else:
469
+ target.set_result(lf.result())
470
+
471
+ loop_fut.add_done_callback(forward)
472
+
372
473
 
373
474
  class UnifiedIterator[T](Iterator[T], AsyncIterator[T]):
374
475
  """
@@ -1,2 +0,0 @@
1
- __version__ = "1.6.2"
2
- __version_tuple__ = (1, 6, 2)
File without changes
File without changes
File without changes
File without changes
File without changes