without-async 0.0.0__tar.gz → 0.0.7__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.
- without_async-0.0.7/PKG-INFO +43 -0
- without_async-0.0.7/README.md +24 -0
- without_async-0.0.7/pyproject.toml +27 -0
- without_async-0.0.7/pyproject.toml.orig +26 -0
- without_async-0.0.7/src/without_async/__init__.py +19 -0
- without_async-0.0.7/src/without_async/durations.py +92 -0
- without_async-0.0.7/src/without_async/tasks.py +143 -0
- without_async-0.0.7/src/without_async/testing.py +62 -0
- without_async-0.0.0/PKG-INFO +0 -5
- without_async-0.0.0/pyproject.toml +0 -9
- without_async-0.0.0/pyproject.toml.orig +0 -9
- /without_async-0.0.0/src/without_async/__init__.py → /without_async-0.0.7/src/without_async/py.typed +0 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: without-async
|
|
3
|
+
Version: 0.0.7
|
|
4
|
+
Summary: The asyncio primitives without is built from: scoped background tasks, bounded concurrency, optional timeouts, and durations that cross an integer boundary.
|
|
5
|
+
Author: Josh Karpel
|
|
6
|
+
Author-email: Josh Karpel <josh.karpel@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
9
|
+
Classifier: Framework :: AsyncIO
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.14
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# without-async
|
|
21
|
+
|
|
22
|
+
The asyncio primitives the rest of `without` is built from. It sits below the
|
|
23
|
+
substrate and speaks only the standard library: every signature here mentions
|
|
24
|
+
`asyncio`, `collections.abc`, and `datetime` types and nothing else, which is
|
|
25
|
+
what lets a package take it without taking the stream vocabulary along.
|
|
26
|
+
|
|
27
|
+
- `background_task` starts a task on entry and cancels-then-awaits it on exit,
|
|
28
|
+
so nothing leaks past the `with` block.
|
|
29
|
+
- `timeout` models "no limit" as `None` rather than as a sentinel float.
|
|
30
|
+
- `limit_concurrency` drives work from a source only while below the limit, so a
|
|
31
|
+
lazy source is never advanced past it, over `cancel_futures` and
|
|
32
|
+
`as_async_iterator`.
|
|
33
|
+
- `Seconds` and `Milliseconds` are counts, not durations: a boundary that
|
|
34
|
+
carries whole units declares one instead of a `timedelta` it would silently
|
|
35
|
+
truncate. These are the two symbols the package name does not cover; they sit
|
|
36
|
+
here because every duration they carry is one something waits out.
|
|
37
|
+
- `without_async.testing` holds `yield_once` and `resolved_next_turn`, the two
|
|
38
|
+
deterministic single-turn helpers a test reaches for when the code it waits on
|
|
39
|
+
offers no signal of its own.
|
|
40
|
+
|
|
41
|
+
See the [`without-async` guide](https://without.help/without-async/)
|
|
42
|
+
(with the [API reference](https://without.help/without-async/reference/))
|
|
43
|
+
for the full surface.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# without-async
|
|
2
|
+
|
|
3
|
+
The asyncio primitives the rest of `without` is built from. It sits below the
|
|
4
|
+
substrate and speaks only the standard library: every signature here mentions
|
|
5
|
+
`asyncio`, `collections.abc`, and `datetime` types and nothing else, which is
|
|
6
|
+
what lets a package take it without taking the stream vocabulary along.
|
|
7
|
+
|
|
8
|
+
- `background_task` starts a task on entry and cancels-then-awaits it on exit,
|
|
9
|
+
so nothing leaks past the `with` block.
|
|
10
|
+
- `timeout` models "no limit" as `None` rather than as a sentinel float.
|
|
11
|
+
- `limit_concurrency` drives work from a source only while below the limit, so a
|
|
12
|
+
lazy source is never advanced past it, over `cancel_futures` and
|
|
13
|
+
`as_async_iterator`.
|
|
14
|
+
- `Seconds` and `Milliseconds` are counts, not durations: a boundary that
|
|
15
|
+
carries whole units declares one instead of a `timedelta` it would silently
|
|
16
|
+
truncate. These are the two symbols the package name does not cover; they sit
|
|
17
|
+
here because every duration they carry is one something waits out.
|
|
18
|
+
- `without_async.testing` holds `yield_once` and `resolved_next_turn`, the two
|
|
19
|
+
deterministic single-turn helpers a test reaches for when the code it waits on
|
|
20
|
+
offers no signal of its own.
|
|
21
|
+
|
|
22
|
+
See the [`without-async` guide](https://without.help/without-async/)
|
|
23
|
+
(with the [API reference](https://without.help/without-async/reference/))
|
|
24
|
+
for the full surface.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.12.3,<0.13"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "without-async"
|
|
7
|
+
version = "0.0.7"
|
|
8
|
+
description = "The asyncio primitives without is built from: scoped background tasks, bounded concurrency, optional timeouts, and durations that cross an integer boundary."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.14"
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
14
|
+
"Framework :: AsyncIO",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
19
|
+
"Programming Language :: Python :: 3.14",
|
|
20
|
+
"Topic :: Software Development :: Libraries",
|
|
21
|
+
"Typing :: Typed",
|
|
22
|
+
]
|
|
23
|
+
dependencies = []
|
|
24
|
+
|
|
25
|
+
[[project.authors]]
|
|
26
|
+
name = "Josh Karpel"
|
|
27
|
+
email = "josh.karpel@gmail.com"
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.12.3,<0.13"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "without-async"
|
|
7
|
+
version = "0.0.7"
|
|
8
|
+
description = "The asyncio primitives without is built from: scoped background tasks, bounded concurrency, optional timeouts, and durations that cross an integer boundary."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "Josh Karpel", email = "josh.karpel@gmail.com" },
|
|
13
|
+
]
|
|
14
|
+
requires-python = ">=3.14"
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
17
|
+
"Framework :: AsyncIO",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
22
|
+
"Programming Language :: Python :: 3.14",
|
|
23
|
+
"Topic :: Software Development :: Libraries",
|
|
24
|
+
"Typing :: Typed",
|
|
25
|
+
]
|
|
26
|
+
dependencies = []
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from without_async.durations import Milliseconds
|
|
2
|
+
from without_async.durations import Seconds
|
|
3
|
+
from without_async.tasks import as_async_iterator
|
|
4
|
+
from without_async.tasks import background_task
|
|
5
|
+
from without_async.tasks import cancel_futures
|
|
6
|
+
from without_async.tasks import limit_concurrency
|
|
7
|
+
from without_async.tasks import sleep_forever
|
|
8
|
+
from without_async.tasks import timeout
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"Milliseconds",
|
|
12
|
+
"Seconds",
|
|
13
|
+
"as_async_iterator",
|
|
14
|
+
"background_task",
|
|
15
|
+
"cancel_futures",
|
|
16
|
+
"limit_concurrency",
|
|
17
|
+
"sleep_forever",
|
|
18
|
+
"timeout",
|
|
19
|
+
]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import timedelta
|
|
5
|
+
|
|
6
|
+
# A `timedelta` is the right type for a duration everywhere in this workspace, and the
|
|
7
|
+
# reason is exactly why these exist: it names its unit, so nothing downstream has to guess
|
|
8
|
+
# whether a bare number meant seconds or milliseconds. What it cannot do is say that a
|
|
9
|
+
# duration *survives* the boundary it is about to cross. A TCP keepalive knob carries whole
|
|
10
|
+
# seconds, an SSE `retry:` line carries whole milliseconds, and SQLite's `busy_timeout`
|
|
11
|
+
# carries whole milliseconds, so each one truncates whatever it is handed. Truncation is at
|
|
12
|
+
# its worst where it is least visible: half a millisecond of `retry:` becomes `retry: 0`,
|
|
13
|
+
# which does not mean "almost no wait", it means "reconnect immediately".
|
|
14
|
+
#
|
|
15
|
+
# So a boundary of that kind declares one of these instead, and what makes them worth
|
|
16
|
+
# having is what they *cannot* hold. Each is a count, not a duration: there is no argument
|
|
17
|
+
# to either constructor that names a finer unit, so a value too fine to cross is not a
|
|
18
|
+
# value the type can be asked to carry. `of` is the one way an arbitrary `timedelta`
|
|
19
|
+
# becomes one, and the only place the question "does this divide?" is ever asked.
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"Milliseconds",
|
|
23
|
+
"Seconds",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _counted(duration: timedelta, unit: timedelta, units: str) -> int:
|
|
28
|
+
count, remainder = divmod(duration, unit)
|
|
29
|
+
if remainder:
|
|
30
|
+
raise ValueError(f"a whole number of {units} cannot express {duration}")
|
|
31
|
+
return count
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class Seconds:
|
|
36
|
+
"""
|
|
37
|
+
A duration a boundary carrying integer seconds can express exactly.
|
|
38
|
+
|
|
39
|
+
The count itself, rather than a `timedelta` that happens to divide by one second: a
|
|
40
|
+
duration finer than the boundary carries is not something this can be constructed
|
|
41
|
+
from, so nothing downstream has a truncation left to do.
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
tcp_keepalive(idle=Seconds(60), interval=Seconds(10))
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`duration` is the `timedelta` back out, for arithmetic and for anything that takes a
|
|
48
|
+
plain duration. `of` is the way in from one, and the only place the question of
|
|
49
|
+
whether it divides is asked:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
Seconds.of(settings.keepalive_idle) # raises on a duration finer than a second
|
|
53
|
+
```
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
count: int
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def duration(self) -> timedelta:
|
|
60
|
+
"""The `timedelta` this count of seconds names."""
|
|
61
|
+
return timedelta(seconds=self.count)
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def of(cls, duration: timedelta) -> Seconds:
|
|
65
|
+
"""Parse a `timedelta` into a count of seconds, refusing a finer duration."""
|
|
66
|
+
return cls(_counted(duration, timedelta(seconds=1), "seconds"))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True, slots=True)
|
|
70
|
+
class Milliseconds:
|
|
71
|
+
"""
|
|
72
|
+
A duration a boundary carrying integer milliseconds can express exactly.
|
|
73
|
+
|
|
74
|
+
The millisecond counterpart of `Seconds`, with the same shape: a count in, `duration`
|
|
75
|
+
back out, and `of` to parse one from a `timedelta`.
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
yield Retry(Milliseconds.of(timedelta(seconds=30)))
|
|
79
|
+
```
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
count: int
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def duration(self) -> timedelta:
|
|
86
|
+
"""The `timedelta` this count of milliseconds names."""
|
|
87
|
+
return timedelta(milliseconds=self.count)
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def of(cls, duration: timedelta) -> Milliseconds:
|
|
91
|
+
"""Parse a `timedelta` into a count of milliseconds, refusing a finer duration."""
|
|
92
|
+
return cls(_counted(duration, timedelta(milliseconds=1), "milliseconds"))
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import AsyncIterable
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
6
|
+
from collections.abc import Awaitable
|
|
7
|
+
from collections.abc import Coroutine
|
|
8
|
+
from collections.abc import Iterable
|
|
9
|
+
from contextlib import asynccontextmanager
|
|
10
|
+
from contextlib import suppress
|
|
11
|
+
from datetime import timedelta
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@asynccontextmanager
|
|
15
|
+
async def timeout(duration: timedelta | None) -> AsyncIterator[None]:
|
|
16
|
+
"""
|
|
17
|
+
Bound the `with` block by `duration`, or leave it unbounded when `None`.
|
|
18
|
+
|
|
19
|
+
A `timedelta`-typed, nullable wrapper over `asyncio.timeout`: `None` disables the
|
|
20
|
+
bound (an always-open context), and a duration raises `TimeoutError` if the block
|
|
21
|
+
outlives it. Modelling "no limit" as `None` keeps that choice a first-class value at
|
|
22
|
+
the call site, rather than a sentinel float threaded through the same parameter.
|
|
23
|
+
"""
|
|
24
|
+
if duration is None:
|
|
25
|
+
yield
|
|
26
|
+
return
|
|
27
|
+
async with asyncio.timeout(duration.total_seconds()):
|
|
28
|
+
yield
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def sleep_forever() -> None:
|
|
32
|
+
"""
|
|
33
|
+
Suspend the current task until it is cancelled.
|
|
34
|
+
|
|
35
|
+
The idiom for a coroutine whose job is to stay alive until its surrounding
|
|
36
|
+
scope tears it down: a server's run loop holding a bound socket open, a
|
|
37
|
+
process that should idle until signalled. It awaits a future that never
|
|
38
|
+
resolves, so it consumes nothing and ends only on cancellation.
|
|
39
|
+
"""
|
|
40
|
+
await asyncio.get_running_loop().create_future()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def cancel_futures[T](futures: Iterable[asyncio.Future[T] | None]) -> None:
|
|
44
|
+
"""
|
|
45
|
+
Cancel every future, then await them all so their teardown completes.
|
|
46
|
+
|
|
47
|
+
Two phases on purpose: cancelling the whole set *before* awaiting any of them
|
|
48
|
+
lets them tear down concurrently, instead of serially cancelling and waiting
|
|
49
|
+
for one at a time. `None` entries are skipped, so a caller holding an optional
|
|
50
|
+
task (`task: asyncio.Task | None`) can pass it without a guard. The futures are
|
|
51
|
+
materialized first, so a caller may pass a live set the awaits will mutate. Each
|
|
52
|
+
future's own `CancelledError` is suppressed; any other exception it raises during
|
|
53
|
+
teardown propagates, which also ends the loop, so the futures behind it in the set
|
|
54
|
+
are cancelled but never awaited. That is the right order for a set of *pending*
|
|
55
|
+
futures, where nothing has an exception to raise; pass one that may already hold a
|
|
56
|
+
failure and the ones behind it lose their teardown to it. Filter to what is still
|
|
57
|
+
running when the set can contain both.
|
|
58
|
+
"""
|
|
59
|
+
present = [future for future in futures if future is not None]
|
|
60
|
+
for future in present:
|
|
61
|
+
future.cancel()
|
|
62
|
+
for future in present:
|
|
63
|
+
with suppress(asyncio.CancelledError):
|
|
64
|
+
await future
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@asynccontextmanager
|
|
68
|
+
async def background_task[T](coro: Coroutine[object, object, T]) -> AsyncIterator[asyncio.Task[T]]:
|
|
69
|
+
"""
|
|
70
|
+
Run `coro` as a task for the duration of the `with` block.
|
|
71
|
+
|
|
72
|
+
The task is started on entry and cancelled (then awaited) on exit, so it is
|
|
73
|
+
bounded by the block and never leaks. If it finishes on its own with an
|
|
74
|
+
exception, that surfaces when the block exits.
|
|
75
|
+
"""
|
|
76
|
+
task = asyncio.create_task(coro)
|
|
77
|
+
try:
|
|
78
|
+
yield task
|
|
79
|
+
finally:
|
|
80
|
+
await cancel_futures([task])
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def as_async_iterator[T](items: AsyncIterable[T] | Iterable[T]) -> AsyncIterator[T]:
|
|
84
|
+
"""
|
|
85
|
+
Normalize a sync or async iterable into a single async iterator.
|
|
86
|
+
|
|
87
|
+
Lets code that consumes via `async for`/`anext` accept either kind without
|
|
88
|
+
branching on the iteration protocol at every use.
|
|
89
|
+
"""
|
|
90
|
+
if isinstance(items, AsyncIterable):
|
|
91
|
+
async for item in items:
|
|
92
|
+
yield item
|
|
93
|
+
else:
|
|
94
|
+
for item in items:
|
|
95
|
+
yield item
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def limit_concurrency[T](
|
|
99
|
+
aws: AsyncIterable[Awaitable[T]] | Iterable[Awaitable[T]],
|
|
100
|
+
limit: int,
|
|
101
|
+
) -> AsyncIterator[asyncio.Future[T]]:
|
|
102
|
+
"""
|
|
103
|
+
Run awaitables from `aws` with at most `limit` in flight, yielding each as it finishes.
|
|
104
|
+
|
|
105
|
+
A bounded-concurrency driver: it pulls the next awaitable from `aws` only
|
|
106
|
+
while fewer than `limit` are already running. So a *lazy* source (an async
|
|
107
|
+
generator that produces each unit of work on demand) is never advanced past
|
|
108
|
+
the limit. That is what lets it gate a side-effecting source: an accept loop
|
|
109
|
+
whose generator awaits `socket.accept()` only when pulled will never accept
|
|
110
|
+
more connections than it can serve.
|
|
111
|
+
|
|
112
|
+
Each completed awaitable is yielded as a `Future`; call `.result()` on it to
|
|
113
|
+
read the value or re-raise its exception. On early exit or cancellation, any
|
|
114
|
+
still-running awaitables are cancelled and awaited, so none outlive the
|
|
115
|
+
iteration.
|
|
116
|
+
|
|
117
|
+
`limit` must be at least 1; a non-positive limit is a `ValueError`, since it
|
|
118
|
+
could only ever stall the source rather than run it.
|
|
119
|
+
|
|
120
|
+
Adapted from [Limiting concurrency in
|
|
121
|
+
asyncio](https://death.andgravity.com/limit-concurrency).
|
|
122
|
+
"""
|
|
123
|
+
if limit < 1:
|
|
124
|
+
raise ValueError(f"limit must be at least 1, but got {limit}")
|
|
125
|
+
source = as_async_iterator(aws)
|
|
126
|
+
ended = False # pragma: no mutate - only ever read as a bool, never identity-compared
|
|
127
|
+
pending: set[asyncio.Future[T]] = set()
|
|
128
|
+
try:
|
|
129
|
+
while pending or not ended:
|
|
130
|
+
while len(pending) < limit and not ended:
|
|
131
|
+
try:
|
|
132
|
+
aw = await anext(source)
|
|
133
|
+
except StopAsyncIteration:
|
|
134
|
+
ended = True
|
|
135
|
+
else:
|
|
136
|
+
pending.add(asyncio.ensure_future(aw))
|
|
137
|
+
if not pending:
|
|
138
|
+
return
|
|
139
|
+
done, pending = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED)
|
|
140
|
+
while done:
|
|
141
|
+
yield done.pop()
|
|
142
|
+
finally:
|
|
143
|
+
await cancel_futures(pending)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
async def yield_once() -> None:
|
|
7
|
+
"""
|
|
8
|
+
Yield control for exactly one event-loop turn, so a just-scheduled task
|
|
9
|
+
reaches its first suspension point.
|
|
10
|
+
|
|
11
|
+
Use this *only* to let a task you just `create_task`-d run far enough to park
|
|
12
|
+
on its first `await`, and *only* when the code it parks in gives you nothing
|
|
13
|
+
to wait on: a library coroutine like `sleep_forever`, or `Sample.updated`
|
|
14
|
+
registering its waiter just before it suspends. Proving such a task *blocks*
|
|
15
|
+
(rather than completing) means giving it one turn and then observing it is
|
|
16
|
+
still pending; there is no signal to await instead, because the suspension
|
|
17
|
+
happens inside code you do not control. Cancelling before that turn would
|
|
18
|
+
preempt the task at its very first step, so the test would pass even for a
|
|
19
|
+
task that never blocks: the one turn is what makes the assertion meaningful.
|
|
20
|
+
|
|
21
|
+
It is one deterministic turn, not a timing guess. The scheduled task can only
|
|
22
|
+
park or finish within that turn, so a single `yield_once()` is exact; it never
|
|
23
|
+
needs a second call or a retry loop.
|
|
24
|
+
|
|
25
|
+
Avoid it everywhere a real signal exists, which is almost everywhere. If the
|
|
26
|
+
code you are waiting on is yours, have it announce its progress and wait on
|
|
27
|
+
that:
|
|
28
|
+
|
|
29
|
+
- waiting for a worker to reach a point: `set` an `asyncio.Event` (or
|
|
30
|
+
`release` a `Semaphore`) there and `await` it;
|
|
31
|
+
- waiting for a sampled `Context` to pick up the next value: `await
|
|
32
|
+
context.updated()`;
|
|
33
|
+
- waiting for a stream or queue to drain: drive it to completion and assert
|
|
34
|
+
on the result.
|
|
35
|
+
|
|
36
|
+
Reaching for `yield_once()` to "wait until the background work has probably
|
|
37
|
+
caught up" is the anti-pattern it must not become: yielding once and *hoping*
|
|
38
|
+
is a race, no matter how many times you call it. Use an explicit signal there.
|
|
39
|
+
"""
|
|
40
|
+
await asyncio.sleep(0)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def resolved_next_turn[T](value: T) -> T:
|
|
44
|
+
"""
|
|
45
|
+
Suspend for one event-loop turn, then return `value`.
|
|
46
|
+
|
|
47
|
+
A deterministic stand-in for awaited contained I/O (a DB read, an RPC) in a
|
|
48
|
+
test that needs a step or a unit of work to *genuinely* suspend and resume.
|
|
49
|
+
The future is resolved on the next turn via `call_soon`, which forces a real
|
|
50
|
+
suspension; awaiting an already-resolved future returns without ever yielding
|
|
51
|
+
control, so the "contained I/O suspends" path would go untested. Like
|
|
52
|
+
`yield_once`, it is one deterministic turn, not a timing guess.
|
|
53
|
+
"""
|
|
54
|
+
loop = asyncio.get_running_loop()
|
|
55
|
+
result: asyncio.Future[T] = loop.create_future()
|
|
56
|
+
|
|
57
|
+
def resolve() -> None:
|
|
58
|
+
if not result.done(): # the awaiting task may have cancelled `result` before this turn
|
|
59
|
+
result.set_result(value)
|
|
60
|
+
|
|
61
|
+
loop.call_soon(resolve)
|
|
62
|
+
return await result
|
without_async-0.0.0/PKG-INFO
DELETED
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
[build-system]
|
|
2
|
-
requires = ["uv_build>=0.11,<0.12"]
|
|
3
|
-
build-backend = "uv_build"
|
|
4
|
-
|
|
5
|
-
[project]
|
|
6
|
-
name = "without-async"
|
|
7
|
-
version = "0.0.0"
|
|
8
|
-
description = "Placeholder reserving the PyPI project name; the first real release supersedes it."
|
|
9
|
-
requires-python = ">=3.9"
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
[build-system]
|
|
2
|
-
requires = ["uv_build>=0.11,<0.12"]
|
|
3
|
-
build-backend = "uv_build"
|
|
4
|
-
|
|
5
|
-
[project]
|
|
6
|
-
name = "without-async"
|
|
7
|
-
version = "0.0.0"
|
|
8
|
-
description = "Placeholder reserving the PyPI project name; the first real release supersedes it."
|
|
9
|
-
requires-python = ">=3.9"
|
/without_async-0.0.0/src/without_async/__init__.py → /without_async-0.0.7/src/without_async/py.typed
RENAMED
|
File without changes
|