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.
@@ -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
@@ -1,5 +0,0 @@
1
- Metadata-Version: 2.3
2
- Name: without-async
3
- Version: 0.0.0
4
- Summary: Placeholder reserving the PyPI project name; the first real release supersedes it.
5
- 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"
@@ -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"