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 ADDED
@@ -0,0 +1,3 @@
1
+ from .routine import routine as routine
2
+
3
+ __all__ = ["routine"]
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
+ ```