noio 0.1__py3-none-any.whl
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.
- noio/__init__.py +3 -0
- noio/_lock.py +49 -0
- noio/_lock_test.py +358 -0
- noio/conftest.py +160 -0
- noio/gevent_test.py +35 -0
- noio/py.typed +0 -0
- noio/routine.md +35 -0
- noio/routine.py +108 -0
- noio/routine_test.py +571 -0
- noio-0.1.dist-info/METADATA +77 -0
- noio-0.1.dist-info/RECORD +13 -0
- noio-0.1.dist-info/WHEEL +4 -0
- noio-0.1.dist-info/licenses/LICENSE +21 -0
noio/__init__.py
ADDED
noio/_lock.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from types import TracebackType
|
|
2
|
+
from typing import Self
|
|
3
|
+
|
|
4
|
+
import aiologic
|
|
5
|
+
from aiologic.lowlevel import current_async_library
|
|
6
|
+
|
|
7
|
+
from .routine import _sync_call
|
|
8
|
+
from .routine import routine
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Lock:
|
|
12
|
+
"""An owned, non-reentrant lock shared by threads, greenlets, and async tasks."""
|
|
13
|
+
|
|
14
|
+
def __init__(self) -> None:
|
|
15
|
+
self.__lock = aiologic.Lock()
|
|
16
|
+
|
|
17
|
+
@routine
|
|
18
|
+
async def __enter__(self) -> Self:
|
|
19
|
+
"""Wait for ownership; reacquiring raises RuntimeError."""
|
|
20
|
+
if _sync_call.get():
|
|
21
|
+
self.__lock.green_acquire()
|
|
22
|
+
else:
|
|
23
|
+
await self.__lock.async_acquire()
|
|
24
|
+
return self
|
|
25
|
+
|
|
26
|
+
@routine
|
|
27
|
+
async def __exit__(
|
|
28
|
+
self,
|
|
29
|
+
exc_type: type[BaseException] | None,
|
|
30
|
+
exc_value: BaseException | None,
|
|
31
|
+
traceback: TracebackType | None,
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Release from the owning thread, greenlet, or async task."""
|
|
34
|
+
# An ordinary acquisition inside an async function still has green ownership.
|
|
35
|
+
if self.__lock.green_owned() or current_async_library(failsafe=True) is None:
|
|
36
|
+
self.__lock.green_release()
|
|
37
|
+
else:
|
|
38
|
+
self.__lock.async_release()
|
|
39
|
+
|
|
40
|
+
async def __aenter__(self) -> Self:
|
|
41
|
+
return await self.__enter__.a()
|
|
42
|
+
|
|
43
|
+
async def __aexit__(
|
|
44
|
+
self,
|
|
45
|
+
exc_type: type[BaseException] | None,
|
|
46
|
+
exc_value: BaseException | None,
|
|
47
|
+
traceback: TracebackType | None,
|
|
48
|
+
) -> None:
|
|
49
|
+
await self.__exit__.a(exc_type, exc_value, traceback)
|
noio/_lock_test.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
3
|
+
from contextlib import AsyncExitStack
|
|
4
|
+
from contextlib import ExitStack
|
|
5
|
+
from threading import Event
|
|
6
|
+
from typing import assert_type
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from noio import routine
|
|
11
|
+
from noio._lock import Lock
|
|
12
|
+
|
|
13
|
+
from .conftest import AsyncRuntime
|
|
14
|
+
from .conftest import RunSync
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _check_sync(checkpoint: Callable[[], None]) -> None:
|
|
18
|
+
lock = Lock()
|
|
19
|
+
with lock as entered:
|
|
20
|
+
assert assert_type(entered, Lock) is lock
|
|
21
|
+
with pytest.raises(RuntimeError), lock:
|
|
22
|
+
pass
|
|
23
|
+
checkpoint()
|
|
24
|
+
with pytest.raises(RuntimeError):
|
|
25
|
+
lock.__exit__(None, None, None)
|
|
26
|
+
|
|
27
|
+
error = ValueError("body failed")
|
|
28
|
+
with pytest.raises(ValueError) as raised, lock as entered:
|
|
29
|
+
assert assert_type(entered, Lock) is lock
|
|
30
|
+
raise error
|
|
31
|
+
assert raised.value is error
|
|
32
|
+
|
|
33
|
+
@routine
|
|
34
|
+
async def inner() -> None:
|
|
35
|
+
async with lock as entered:
|
|
36
|
+
assert entered is lock
|
|
37
|
+
checkpoint()
|
|
38
|
+
with pytest.raises(RuntimeError, match="Use await function.a"), lock:
|
|
39
|
+
pass
|
|
40
|
+
with pytest.raises(RuntimeError, match="Use await function.a"):
|
|
41
|
+
lock.__exit__(None, None, None)
|
|
42
|
+
|
|
43
|
+
@routine
|
|
44
|
+
async def outer() -> None:
|
|
45
|
+
await inner.a()
|
|
46
|
+
async with AsyncExitStack() as stack:
|
|
47
|
+
assert await stack.enter_async_context(lock) is lock
|
|
48
|
+
|
|
49
|
+
outer()
|
|
50
|
+
with ExitStack() as stack:
|
|
51
|
+
# Pyright does not bind routine descriptors during protocol matching.
|
|
52
|
+
assert stack.enter_context(lock) is lock # pyright: ignore[reportArgumentType]
|
|
53
|
+
checkpoint()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_sync_calls(sync_runtime: RunSync) -> None:
|
|
57
|
+
sync_runtime(_check_sync)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_thread_contention_and_ownership() -> None:
|
|
61
|
+
lock = Lock()
|
|
62
|
+
started = Event()
|
|
63
|
+
entered = Event()
|
|
64
|
+
|
|
65
|
+
@routine
|
|
66
|
+
async def nested() -> None:
|
|
67
|
+
async with lock:
|
|
68
|
+
entered.set()
|
|
69
|
+
|
|
70
|
+
def worker() -> None:
|
|
71
|
+
with pytest.raises(RuntimeError):
|
|
72
|
+
lock.__exit__(None, None, None)
|
|
73
|
+
started.set()
|
|
74
|
+
nested()
|
|
75
|
+
|
|
76
|
+
with ThreadPoolExecutor(max_workers=1) as pool:
|
|
77
|
+
with lock:
|
|
78
|
+
future = pool.submit(worker)
|
|
79
|
+
assert started.wait(2)
|
|
80
|
+
assert not entered.is_set()
|
|
81
|
+
future.result(timeout=2)
|
|
82
|
+
assert entered.is_set()
|
|
83
|
+
with lock:
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _check_green_contention(checkpoint: Callable[[], None]) -> None:
|
|
88
|
+
import gevent
|
|
89
|
+
|
|
90
|
+
lock = Lock()
|
|
91
|
+
entered = []
|
|
92
|
+
|
|
93
|
+
@routine
|
|
94
|
+
async def contender() -> None:
|
|
95
|
+
async with lock:
|
|
96
|
+
entered.append(True)
|
|
97
|
+
checkpoint()
|
|
98
|
+
|
|
99
|
+
with lock:
|
|
100
|
+
task = gevent.spawn(contender)
|
|
101
|
+
checkpoint()
|
|
102
|
+
assert not entered
|
|
103
|
+
task.get(timeout=2)
|
|
104
|
+
assert entered == [True]
|
|
105
|
+
|
|
106
|
+
with lock:
|
|
107
|
+
cancelled = gevent.spawn(contender)
|
|
108
|
+
checkpoint()
|
|
109
|
+
cancelled.kill()
|
|
110
|
+
contender()
|
|
111
|
+
assert entered == [True, True]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@pytest.mark.parametrize(
|
|
115
|
+
"sync_runtime",
|
|
116
|
+
["gevent", "gevent-patched-before-import", "gevent-patched-after-import"],
|
|
117
|
+
indirect=True,
|
|
118
|
+
)
|
|
119
|
+
def test_green_contention_and_cancellation(sync_runtime: RunSync) -> None:
|
|
120
|
+
sync_runtime(_check_green_contention)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_async_calls(async_runtime: AsyncRuntime) -> None:
|
|
124
|
+
lock = Lock()
|
|
125
|
+
|
|
126
|
+
@routine
|
|
127
|
+
async def inner() -> None:
|
|
128
|
+
async with lock as entered:
|
|
129
|
+
assert assert_type(entered, Lock) is lock
|
|
130
|
+
await async_runtime.checkpoint()
|
|
131
|
+
with pytest.raises(RuntimeError, match="Use await function.a"), lock:
|
|
132
|
+
pass
|
|
133
|
+
with pytest.raises(RuntimeError, match="Use await function.a"):
|
|
134
|
+
lock.__exit__(None, None, None)
|
|
135
|
+
with pytest.raises(RuntimeError):
|
|
136
|
+
async with lock:
|
|
137
|
+
pass
|
|
138
|
+
|
|
139
|
+
@routine
|
|
140
|
+
async def outer() -> None:
|
|
141
|
+
await inner.a()
|
|
142
|
+
|
|
143
|
+
async def main() -> None:
|
|
144
|
+
# Creating the acquisition coroutine must not acquire the lock.
|
|
145
|
+
call = lock.__aenter__()
|
|
146
|
+
async with lock:
|
|
147
|
+
pass
|
|
148
|
+
assert assert_type(await call, Lock) is lock
|
|
149
|
+
await lock.__aexit__(None, None, None)
|
|
150
|
+
with pytest.raises(RuntimeError):
|
|
151
|
+
await lock.__aexit__(None, None, None)
|
|
152
|
+
await outer.a()
|
|
153
|
+
with pytest.raises(ValueError, match="body failed"):
|
|
154
|
+
async with lock:
|
|
155
|
+
raise ValueError("body failed")
|
|
156
|
+
async with lock:
|
|
157
|
+
pass
|
|
158
|
+
# Ordinary ownership is also valid inside an async function.
|
|
159
|
+
with lock:
|
|
160
|
+
pass
|
|
161
|
+
|
|
162
|
+
# A failed ordinary routine must not leak ordinary dispatch into async work.
|
|
163
|
+
@routine
|
|
164
|
+
async def fails() -> None:
|
|
165
|
+
raise ValueError("routine failed")
|
|
166
|
+
|
|
167
|
+
with pytest.raises(ValueError):
|
|
168
|
+
fails()
|
|
169
|
+
await outer.a()
|
|
170
|
+
|
|
171
|
+
async_runtime.run(main)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_async_contention_and_ownership(async_runtime: AsyncRuntime) -> None:
|
|
175
|
+
lock = Lock()
|
|
176
|
+
|
|
177
|
+
async def main() -> None:
|
|
178
|
+
held = async_runtime.event()
|
|
179
|
+
attempted = async_runtime.event()
|
|
180
|
+
order = []
|
|
181
|
+
|
|
182
|
+
async def owner() -> None:
|
|
183
|
+
async with lock:
|
|
184
|
+
held.set()
|
|
185
|
+
await attempted.wait()
|
|
186
|
+
await async_runtime.checkpoint()
|
|
187
|
+
assert order == []
|
|
188
|
+
order.append("owner")
|
|
189
|
+
|
|
190
|
+
@routine
|
|
191
|
+
async def nested() -> None:
|
|
192
|
+
async with lock:
|
|
193
|
+
order.append("waiter")
|
|
194
|
+
|
|
195
|
+
async def waiter() -> None:
|
|
196
|
+
await held.wait()
|
|
197
|
+
with pytest.raises(RuntimeError):
|
|
198
|
+
await lock.__aexit__(None, None, None)
|
|
199
|
+
attempted.set()
|
|
200
|
+
await nested.a()
|
|
201
|
+
|
|
202
|
+
await async_runtime.concurrently(owner, waiter)
|
|
203
|
+
assert order == ["owner", "waiter"]
|
|
204
|
+
|
|
205
|
+
async_runtime.run(main)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_cancelled_acquisition_does_not_take_lock(async_runtime: AsyncRuntime) -> None:
|
|
209
|
+
lock = Lock()
|
|
210
|
+
|
|
211
|
+
async def waiter() -> None:
|
|
212
|
+
async with lock:
|
|
213
|
+
pytest.fail("cancelled waiter acquired the lock")
|
|
214
|
+
|
|
215
|
+
async def main() -> None:
|
|
216
|
+
held = async_runtime.event()
|
|
217
|
+
cancelled = async_runtime.event()
|
|
218
|
+
|
|
219
|
+
async def owner() -> None:
|
|
220
|
+
async with lock:
|
|
221
|
+
held.set()
|
|
222
|
+
await cancelled.wait()
|
|
223
|
+
|
|
224
|
+
async def cancel_waiter() -> None:
|
|
225
|
+
await held.wait()
|
|
226
|
+
try:
|
|
227
|
+
await async_runtime.cancel(waiter)
|
|
228
|
+
finally:
|
|
229
|
+
cancelled.set()
|
|
230
|
+
|
|
231
|
+
await async_runtime.concurrently(owner, cancel_waiter)
|
|
232
|
+
async with lock:
|
|
233
|
+
pass
|
|
234
|
+
|
|
235
|
+
async_runtime.run(main)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def test_thread_and_async_handoff(async_runtime: AsyncRuntime) -> None:
|
|
239
|
+
lock = Lock()
|
|
240
|
+
held = Event()
|
|
241
|
+
release = Event()
|
|
242
|
+
attempted = Event()
|
|
243
|
+
acquired = Event()
|
|
244
|
+
|
|
245
|
+
def worker() -> None:
|
|
246
|
+
with lock:
|
|
247
|
+
held.set()
|
|
248
|
+
assert release.wait(2)
|
|
249
|
+
assert attempted.wait(2)
|
|
250
|
+
with lock:
|
|
251
|
+
acquired.set()
|
|
252
|
+
|
|
253
|
+
async def main() -> None:
|
|
254
|
+
while not held.is_set():
|
|
255
|
+
await async_runtime.checkpoint()
|
|
256
|
+
|
|
257
|
+
async def signal() -> None:
|
|
258
|
+
await async_runtime.checkpoint()
|
|
259
|
+
release.set()
|
|
260
|
+
|
|
261
|
+
async def waiter() -> None:
|
|
262
|
+
async with lock:
|
|
263
|
+
attempted.set()
|
|
264
|
+
await async_runtime.checkpoint()
|
|
265
|
+
assert not acquired.is_set()
|
|
266
|
+
|
|
267
|
+
await async_runtime.concurrently(waiter, signal)
|
|
268
|
+
while not acquired.is_set():
|
|
269
|
+
await async_runtime.checkpoint()
|
|
270
|
+
|
|
271
|
+
with ThreadPoolExecutor(max_workers=1) as pool:
|
|
272
|
+
future = pool.submit(worker)
|
|
273
|
+
try:
|
|
274
|
+
async_runtime.run(main)
|
|
275
|
+
finally:
|
|
276
|
+
release.set()
|
|
277
|
+
attempted.set()
|
|
278
|
+
future.result(timeout=2)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def test_asyncio_and_trio_share_lock() -> None:
|
|
282
|
+
import asyncio
|
|
283
|
+
|
|
284
|
+
import trio
|
|
285
|
+
from trio.testing import wait_all_tasks_blocked
|
|
286
|
+
|
|
287
|
+
lock = Lock()
|
|
288
|
+
waiting = Event()
|
|
289
|
+
acquired = Event()
|
|
290
|
+
|
|
291
|
+
@routine
|
|
292
|
+
async def waiter() -> None:
|
|
293
|
+
async with lock:
|
|
294
|
+
acquired.set()
|
|
295
|
+
|
|
296
|
+
async def trio_main() -> None:
|
|
297
|
+
with trio.fail_after(5):
|
|
298
|
+
async with trio.open_nursery() as nursery:
|
|
299
|
+
nursery.start_soon(waiter.a)
|
|
300
|
+
# Confirm acquisition has suspended before asyncio releases the lock.
|
|
301
|
+
await wait_all_tasks_blocked()
|
|
302
|
+
assert not acquired.is_set()
|
|
303
|
+
waiting.set()
|
|
304
|
+
|
|
305
|
+
with ThreadPoolExecutor(max_workers=1) as pool:
|
|
306
|
+
|
|
307
|
+
@routine
|
|
308
|
+
async def owner() -> None:
|
|
309
|
+
async with lock:
|
|
310
|
+
future = pool.submit(trio.run, trio_main)
|
|
311
|
+
async with asyncio.timeout(5):
|
|
312
|
+
while not waiting.is_set():
|
|
313
|
+
await asyncio.sleep(0)
|
|
314
|
+
assert not acquired.is_set()
|
|
315
|
+
await asyncio.wrap_future(future)
|
|
316
|
+
assert acquired.is_set()
|
|
317
|
+
async with lock:
|
|
318
|
+
pass
|
|
319
|
+
|
|
320
|
+
asyncio.run(owner.a())
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@pytest.mark.parametrize("backend", ["asyncio", "trio"])
|
|
324
|
+
def test_cancellation_while_waiting_and_holding(backend: str) -> None:
|
|
325
|
+
import anyio
|
|
326
|
+
|
|
327
|
+
lock = Lock()
|
|
328
|
+
|
|
329
|
+
async def main() -> None:
|
|
330
|
+
started = anyio.Event()
|
|
331
|
+
finished = anyio.Event()
|
|
332
|
+
scope = anyio.CancelScope()
|
|
333
|
+
|
|
334
|
+
async def waiter() -> None:
|
|
335
|
+
with scope:
|
|
336
|
+
started.set()
|
|
337
|
+
async with lock:
|
|
338
|
+
pytest.fail("cancelled waiter acquired the lock")
|
|
339
|
+
finished.set()
|
|
340
|
+
|
|
341
|
+
async with anyio.create_task_group() as group:
|
|
342
|
+
async with lock:
|
|
343
|
+
group.start_soon(waiter)
|
|
344
|
+
await started.wait()
|
|
345
|
+
scope.cancel()
|
|
346
|
+
await finished.wait()
|
|
347
|
+
async with lock:
|
|
348
|
+
pass
|
|
349
|
+
|
|
350
|
+
with anyio.CancelScope() as owner_scope:
|
|
351
|
+
async with lock:
|
|
352
|
+
owner_scope.cancel()
|
|
353
|
+
await anyio.lowlevel.checkpoint()
|
|
354
|
+
assert owner_scope.cancelled_caught
|
|
355
|
+
async with lock:
|
|
356
|
+
pass
|
|
357
|
+
|
|
358
|
+
anyio.run(main, backend=backend)
|
noio/conftest.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import sys
|
|
3
|
+
import sysconfig
|
|
4
|
+
from collections.abc import Awaitable
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from collections.abc import Coroutine
|
|
7
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from functools import partial
|
|
10
|
+
from time import sleep
|
|
11
|
+
from typing import Any
|
|
12
|
+
from typing import Protocol
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RunAsync(Protocol):
|
|
18
|
+
def __call__[R](self, fn: Callable[[], Coroutine[Any, Any, R]]) -> R: ...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
type AsyncCall = Callable[[], Coroutine[Any, Any, None]]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RunConcurrently(Protocol):
|
|
25
|
+
async def __call__(self, *functions: AsyncCall) -> None: ...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AsyncEvent(Protocol):
|
|
29
|
+
def set(self) -> None: ...
|
|
30
|
+
|
|
31
|
+
async def wait(self) -> object: ...
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class AsyncRuntime:
|
|
36
|
+
run: RunAsync
|
|
37
|
+
checkpoint: Callable[[], Awaitable[None]]
|
|
38
|
+
event: Callable[[], AsyncEvent]
|
|
39
|
+
concurrently: RunConcurrently
|
|
40
|
+
cancel: Callable[[AsyncCall], Awaitable[None]]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.fixture(params=["asyncio", "trio", "anyio-asyncio", "anyio-trio"])
|
|
44
|
+
def async_runtime(request: pytest.FixtureRequest) -> AsyncRuntime:
|
|
45
|
+
import asyncio
|
|
46
|
+
|
|
47
|
+
import anyio
|
|
48
|
+
import trio
|
|
49
|
+
|
|
50
|
+
backend: str = request.param
|
|
51
|
+
|
|
52
|
+
def run[R](fn: Callable[[], Coroutine[Any, Any, R]]) -> R:
|
|
53
|
+
if backend == "asyncio":
|
|
54
|
+
return asyncio.run(fn())
|
|
55
|
+
if backend == "trio":
|
|
56
|
+
return trio.run(fn)
|
|
57
|
+
return anyio.run(fn, backend=backend.removeprefix("anyio-"))
|
|
58
|
+
|
|
59
|
+
async def checkpoint() -> None:
|
|
60
|
+
if backend == "asyncio":
|
|
61
|
+
await asyncio.sleep(0)
|
|
62
|
+
elif backend == "trio":
|
|
63
|
+
await trio.lowlevel.checkpoint()
|
|
64
|
+
else:
|
|
65
|
+
await anyio.lowlevel.checkpoint()
|
|
66
|
+
|
|
67
|
+
def event() -> AsyncEvent:
|
|
68
|
+
if backend == "asyncio":
|
|
69
|
+
return asyncio.Event()
|
|
70
|
+
if backend == "trio":
|
|
71
|
+
return trio.Event()
|
|
72
|
+
return anyio.Event()
|
|
73
|
+
|
|
74
|
+
async def concurrently(*functions: AsyncCall) -> None:
|
|
75
|
+
if backend == "asyncio":
|
|
76
|
+
async with asyncio.TaskGroup() as tasks:
|
|
77
|
+
for fn in functions:
|
|
78
|
+
tasks.create_task(fn())
|
|
79
|
+
elif backend == "trio":
|
|
80
|
+
async with trio.open_nursery() as nursery:
|
|
81
|
+
for fn in functions:
|
|
82
|
+
nursery.start_soon(fn)
|
|
83
|
+
else:
|
|
84
|
+
async with anyio.create_task_group() as group:
|
|
85
|
+
for fn in functions:
|
|
86
|
+
group.start_soon(fn)
|
|
87
|
+
|
|
88
|
+
async def cancel(fn: AsyncCall) -> None:
|
|
89
|
+
if backend == "asyncio":
|
|
90
|
+
|
|
91
|
+
async def cancelled() -> None:
|
|
92
|
+
task = asyncio.current_task()
|
|
93
|
+
assert task is not None
|
|
94
|
+
task.cancel()
|
|
95
|
+
await fn()
|
|
96
|
+
|
|
97
|
+
with pytest.raises(asyncio.CancelledError):
|
|
98
|
+
await asyncio.create_task(cancelled())
|
|
99
|
+
elif backend == "trio":
|
|
100
|
+
with trio.CancelScope() as scope:
|
|
101
|
+
scope.cancel()
|
|
102
|
+
await fn()
|
|
103
|
+
assert scope.cancelled_caught
|
|
104
|
+
else:
|
|
105
|
+
with anyio.CancelScope() as anyio_scope:
|
|
106
|
+
anyio_scope.cancel()
|
|
107
|
+
await fn()
|
|
108
|
+
assert anyio_scope.cancelled_caught
|
|
109
|
+
|
|
110
|
+
return AsyncRuntime(run, checkpoint, event, concurrently, cancel)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
type SyncCheck = Callable[[Callable[[], None]], None]
|
|
114
|
+
type RunSync = Callable[[SyncCheck], None]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@pytest.fixture(
|
|
118
|
+
params=[
|
|
119
|
+
"main-thread",
|
|
120
|
+
"worker-thread",
|
|
121
|
+
"gevent",
|
|
122
|
+
"gevent-patched-before-import",
|
|
123
|
+
"gevent-patched-after-import",
|
|
124
|
+
]
|
|
125
|
+
)
|
|
126
|
+
def sync_runtime(request: pytest.FixtureRequest) -> RunSync:
|
|
127
|
+
"""Run a module-level check, passing the runtime's scheduling checkpoint.
|
|
128
|
+
|
|
129
|
+
Gevent subprocesses import checks by name, so they must be module-level functions.
|
|
130
|
+
"""
|
|
131
|
+
mode: str = request.param
|
|
132
|
+
if mode.startswith("gevent") and sysconfig.get_config_var("Py_GIL_DISABLED"):
|
|
133
|
+
pytest.skip("gevent does not support free-threaded CPython")
|
|
134
|
+
|
|
135
|
+
def run(check: SyncCheck) -> None:
|
|
136
|
+
if mode == "main-thread":
|
|
137
|
+
check(partial(sleep, 0))
|
|
138
|
+
elif mode == "worker-thread":
|
|
139
|
+
with ThreadPoolExecutor(max_workers=1) as executor:
|
|
140
|
+
executor.submit(check, partial(sleep, 0)).result(timeout=2)
|
|
141
|
+
else:
|
|
142
|
+
bootstrap = ""
|
|
143
|
+
if mode == "gevent-patched-after-import":
|
|
144
|
+
bootstrap += "import noio\n"
|
|
145
|
+
if mode != "gevent":
|
|
146
|
+
bootstrap += "from gevent import monkey\nmonkey.patch_all()\n"
|
|
147
|
+
bootstrap += (
|
|
148
|
+
"import gevent, importlib, sys\n"
|
|
149
|
+
"check = getattr(importlib.import_module(sys.argv[1]), sys.argv[2])\n"
|
|
150
|
+
"gevent.spawn(check, lambda: gevent.sleep(0)).get(timeout=2)\n"
|
|
151
|
+
)
|
|
152
|
+
result = subprocess.run(
|
|
153
|
+
[sys.executable, "-c", bootstrap, check.__module__, check.__name__],
|
|
154
|
+
capture_output=True,
|
|
155
|
+
text=True,
|
|
156
|
+
timeout=10,
|
|
157
|
+
)
|
|
158
|
+
assert result.returncode == 0, result.stdout + result.stderr
|
|
159
|
+
|
|
160
|
+
return run
|
noio/gevent_test.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import sys
|
|
3
|
+
import sysconfig
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.mark.skipif(
|
|
9
|
+
not sysconfig.get_config_var("Py_GIL_DISABLED"),
|
|
10
|
+
reason="requires a free-threaded CPython build",
|
|
11
|
+
)
|
|
12
|
+
@pytest.mark.xfail(
|
|
13
|
+
strict=True,
|
|
14
|
+
raises=AssertionError,
|
|
15
|
+
reason="gevent requires the GIL; revisit sync_runtime skips when this passes",
|
|
16
|
+
)
|
|
17
|
+
def test_gevent_preserves_disabled_gil() -> None:
|
|
18
|
+
# Ignore PYTHON_GIL overrides so this probes gevent's default behavior.
|
|
19
|
+
# Keep imports isolated from the rest of the test suite.
|
|
20
|
+
result = subprocess.run(
|
|
21
|
+
[
|
|
22
|
+
sys.executable,
|
|
23
|
+
"-E",
|
|
24
|
+
"-c",
|
|
25
|
+
"import sys\n"
|
|
26
|
+
"assert not sys._is_gil_enabled()\n"
|
|
27
|
+
"import gevent\n"
|
|
28
|
+
"print(sys._is_gil_enabled())\n",
|
|
29
|
+
],
|
|
30
|
+
capture_output=True,
|
|
31
|
+
text=True,
|
|
32
|
+
timeout=10,
|
|
33
|
+
check=True,
|
|
34
|
+
)
|
|
35
|
+
assert result.stdout.strip() == "False", result.stderr
|
noio/py.typed
ADDED
|
File without changes
|
noio/routine.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
Routine
|
|
2
|
+
========
|
|
3
|
+
|
|
4
|
+
Start with regular functions, which need no decorator.
|
|
5
|
+
When a function also needs to support coroutine libraries,
|
|
6
|
+
write its implementation as an async function decorated with `@noio.routine`.
|
|
7
|
+
Existing callers can continue calling it normally.
|
|
8
|
+
|
|
9
|
+
Inside a routine, use `await function.a(...)` to call other routines.
|
|
10
|
+
|
|
11
|
+
Methods
|
|
12
|
+
-------
|
|
13
|
+
|
|
14
|
+
The `@routine` decorator works on methods.
|
|
15
|
+
If you're using it on a classmethod or staticmethod,
|
|
16
|
+
be sure to put classmethod or staticmethod directly above the function,
|
|
17
|
+
and this decorator above that.
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
import asyncio
|
|
21
|
+
from typing import Self
|
|
22
|
+
import noio
|
|
23
|
+
|
|
24
|
+
class Greeter:
|
|
25
|
+
def __init__(self, name: str):
|
|
26
|
+
self.name = name
|
|
27
|
+
|
|
28
|
+
@noio.routine
|
|
29
|
+
@classmethod
|
|
30
|
+
async def create(cls, name: str) -> Self:
|
|
31
|
+
return cls(name)
|
|
32
|
+
|
|
33
|
+
assert Greeter.create("world").name == "world"
|
|
34
|
+
assert asyncio.run(Greeter.create.a("world")).name == "world"
|
|
35
|
+
```
|