Skip to content

Commit f553d87

Browse files
Added new built-in coroutines.
1 parent dc03286 commit f553d87

File tree

14 files changed

+679
-39
lines changed

14 files changed

+679
-39
lines changed

README.md

Lines changed: 102 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@ assert out == (4, 5)
2626
- Somewhat unusually, our syntax uses `yield` rather than `await`, but the behaviour is the same. Await another coroutine with `yield coro`. Await on multiple with `yield [coro1, coro2, ...]` (a 'gather' in asyncio terminology; a 'nursery' in trio terminology).
2727
- An error in one coroutine will cancel all coroutines across the entire event loop.
2828
- If the erroring coroutine is sequentially depended on by a chain of other coroutines, then we chain their tracebacks for easier debugging.
29-
- Errors even propagate to and from synchronous operations ran in threads.
29+
- Errors propagate to and from synchronous operations ran in threads.
3030
- Can nest tinyio loops inside each other, none of this one-per-thread business.
31-
- Ludicrously simple. No need for futures, tasks, etc. Here's the full API:
31+
- Ludicrously simple. No need for futures, tasks, etc. Here's the entirety of the day-to-day API:
3232
```python
3333
tinyio.Loop
3434
tinyio.run_in_thread
@@ -44,7 +44,7 @@ pip install tinyio
4444
4545
## Documentation
4646
47-
#### Loops
47+
### Loops
4848
4949
Create a loop with `tinyio.Loop()`. It has a single method, `.run(coro)`, which consumes a coroutine, and which returns the output of that coroutine.
5050
@@ -57,9 +57,10 @@ Coroutines can `yield` four possible things:
5757
5858
You can safely `yield` the same coroutine multiple times, e.g. perhaps four coroutines have a diamond dependency pattern, with two coroutines each depending on a single shared one.
5959
60-
#### Threading
60+
### Threading
61+
62+
Blocking functions can be ran in threads using `tinyio.run_in_thread(fn, *args, **kwargs)`, which gives a coroutine you can `yield` on. Example:
6163
62-
Synchronous functions can be ran in threads using `tinyio.run_in_thread(fn, *args, **kwargs)`, which returns a coroutine you can `yield` on:
6364
```python
6465
import time, tinyio
6566
@@ -75,13 +76,12 @@ loop = tinyio.Loop()
7576
out = loop.run(foo(x=1)) # runs in one second, not three
7677
assert out == [2, 2, 2]
7778
```
78-
The thread will call `fn(*args, **kwargs)`.
7979

80-
#### Sleeping
80+
### Sleeping
8181

8282
This is `tinyio.sleep(delay_in_seconds)`, which is a coroutine you can `yield` on.
8383

84-
#### Error propagation
84+
### Error propagation
8585

8686
If any coroutine raises an error, then:
8787

@@ -91,6 +91,100 @@ If any coroutine raises an error, then:
9191

9292
This gives every coroutine a chance to shut down gracefully. Debuggers like [`patdb`](https://github.com/patrick-kidger/patdb) offer the ability to navigate across exceptions in an exception group, allowing you to inspect the state of all coroutines that were related to the error.
9393

94+
### Batteries-included
95+
96+
We ship batteries-included with a collection of standard operations, all built on top of just the functionality you've already seen.
97+
98+
<details><summary>Click to expand</summary>
99+
100+
```python
101+
tinyio.add_done_callback tinyio.Semaphore
102+
tinyio.AsCompleted tinyio.ThreadPool
103+
tinyio.Barrier tinyio.timeout
104+
tinyio.Event tinyio.TimeoutError
105+
tinyio.Lock
106+
```
107+
None of these require special support from the event loop, they are all just simple implementations that you could have written yourself :)
108+
109+
---
110+
111+
- `tinyio.add_done_callback(coro, success_callback)`
112+
113+
Used as `yield {tinyio.add_done_callback(coro, success_callback)}`.
114+
115+
This wraps `coro` so that `success_callback(out)` is called on its output once it completes. Note the `{...}` above, indicating calling this in nonblocking fashion (otherwise you could just directly call the callbacks yourself).
116+
117+
---
118+
119+
- `tinyio.AsCompleted({coro1, coro2, ...})`
120+
121+
This schedules multiple coroutines in the background (like `yield {coro1, coro2, ...}`), and then offers their results in the order they complete.
122+
123+
This is iterated over in the following way, using its `.done()` and `.get()` methods:
124+
```python
125+
def main():
126+
iterator = tinyio.AsCompleted({coro1, coro2, coro3})
127+
while not iterator.done():
128+
x = yield iterator.get()
129+
```
130+
131+
---
132+
133+
- `tinyio.Barrier(value)`
134+
135+
This has a single method `barrier.wait()`, which is a coroutine you can `yield` on. Once `value` many coroutines have yielded on this method then it will unblock.
136+
137+
---
138+
139+
- `tinyio.Event()`
140+
141+
This has a method `.wait()`, which is a coroutine you can `yield` on. This will unblock once its `.set()` method is called (typically from another coroutine). It also has a `is_set()` method for checking whether it has been set.
142+
143+
---
144+
145+
- `tinyio.Lock()`
146+
147+
This is just a convenience for `tinyio.Semaphore(value=1)`, see below.
148+
149+
---
150+
151+
- `tinyio.Semaphore(value)`
152+
153+
This manages an internal counter that is initialised at `value`, is decremented when entering a region, and incremented when exiting. This blocks if this counter is at zero. In this way, at most `value` coroutines may acquire the semaphore at a time.
154+
155+
This is used as:
156+
```python
157+
semaphore = Semaphore(value)
158+
159+
...
160+
161+
with (yield semaphore()):
162+
...
163+
```
164+
165+
---
166+
167+
- `tinyio.timeout(coro, timeout_in_seconds)`
168+
169+
This is a coroutine you can `yield` on, used as `output, success = yield tinyio.timeout(coro, timeout_in_seconds)`.
170+
171+
This runs `coro` for at most `timeout_in_seconds`. If it succeeds in that time then the pair `(output, True)` is returned . Else this will return `(None, False)`, and `coro` will be halted by raising `tinyio.TimeoutError` inside it.
172+
173+
---
174+
175+
- `tinyio.ThreadPool(max_threads)`
176+
177+
This is equivalent to making multiple `tinyio.run_in_thread` calls, but will limit the number of threads to at most `max_threads`. Additional work after that will block until a thread becomes available.
178+
179+
This has two methods:
180+
181+
- `.run_in_thread(fn, *args, **kwargs)`, which is a coroutine you can `yield` on. This is equivalent to `yield tinyio.run_in_thread(fn, *args, **kwargs)`.
182+
- `.map(fn, xs)`, which is a coroutine you can `yield` on. This is equivalent to `yield [tinyio.run_in_thread(fn, x) for x in xs]`.
183+
184+
---
185+
186+
</details>
187+
94188
## FAQ
95189

96190
<details>

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ name = "tinyio"
2020
readme = "README.md"
2121
requires-python = ">=3.11"
2222
urls = {repository = "https://github.com/patrick-kidger/tinyio"}
23-
version = "0.1.2"
23+
version = "0.1.3"
2424

2525
[project.optional-dependencies]
2626
dev = ["pre-commit", "pytest"]

tests/test_background.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import tinyio
2+
3+
4+
def _block(event1: tinyio.Event, event2: tinyio.Event, out):
5+
yield event1.wait()
6+
event2.set()
7+
return out
8+
9+
10+
def _test_done_callback():
11+
out = []
12+
event1 = tinyio.Event()
13+
event2 = tinyio.Event()
14+
event3 = tinyio.Event()
15+
yield {tinyio.add_done_callback(_block(event2, event3, 1), out.append)}
16+
yield {tinyio.add_done_callback(_block(event1, event2, 2), out.append)}
17+
for _ in range(20):
18+
yield
19+
assert len(out) == 0
20+
event1.set()
21+
yield event3.wait()
22+
assert out == [2, 1]
23+
24+
25+
def test_done_callback():
26+
loop = tinyio.Loop()
27+
loop.run(_test_done_callback())
28+
29+
30+
# To reinstate if we ever reintroduce error callbacks.
31+
32+
33+
# def _raises_after(event: tinyio.Event):
34+
# yield event.wait()
35+
# raise RuntimeError("Kaboom")
36+
37+
38+
# def _test_error_callback(error_callback1, error_callback2):
39+
# event1 = tinyio.Event()
40+
# event2 = tinyio.Event()
41+
# yield {tinyio.add_done_callback(_raises_after(event1), _assert_false, error_callback1)}
42+
# yield {tinyio.add_done_callback(_raises_after(event2), _assert_false, error_callback2)}
43+
# event1.set()
44+
45+
46+
# def test_error_callback():
47+
# loop = tinyio.Loop()
48+
# out1 = []
49+
# out2 = []
50+
# with pytest.raises(RuntimeError, match="Kaboom") as catcher:
51+
# loop.run(_test_error_callback(out1.append, out2.append))
52+
# assert len(out1) == 1 and out1[0] is catcher.value
53+
# assert len(out2) == 1 and type(out2[0]) is tinyio.CancelledError
54+
55+
56+
# def test_error_callback_that_raises1():
57+
# loop = tinyio.Loop()
58+
# out = []
59+
# def error_callback(_):
60+
# raise ValueError("Eek")
61+
# with pytest.raises(ValueError, match="Eek"):
62+
# loop.run(_test_error_callback(error_callback, out.append))
63+
# assert len(out) == 1 and type(out[0]) is tinyio.CancelledError
64+
65+
66+
# def test_error_callback_that_raises2():
67+
# loop = tinyio.Loop()
68+
# out = []
69+
# def error_callback(_):
70+
# raise ValueError("Eek")
71+
# with pytest.raises(BaseExceptionGroup) as catcher, pytest.warns(RuntimeWarning, match="leak"):
72+
# loop.run(_test_error_callback(out.append, error_callback))
73+
# [runtime, value] = catcher.value.exceptions
74+
# assert type(runtime) is RuntimeError and str(runtime) == "Kaboom"
75+
# assert type(value) is ValueError and str(value) == "Eek"

tests/test_basic.py

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -84,23 +84,6 @@ def _mul():
8484
assert loop.run(_mul()) == 25
8585

8686

87-
def test_in_thread():
88-
def _blocking_slow_add_one(x: int) -> int:
89-
time.sleep(0.1)
90-
return x + 1
91-
92-
def _big_gather(x: int):
93-
out = yield [tinyio.run_in_thread(_blocking_slow_add_one, x) for _ in range(100)]
94-
return out
95-
96-
loop = tinyio.Loop()
97-
start = time.time()
98-
out = loop.run(_big_gather(1))
99-
end = time.time()
100-
assert out == [2 for _ in range(100)]
101-
assert end - start < 0.5
102-
103-
10487
def test_waiting_on_already_finished():
10588
def f():
10689
yield

tests/test_examples.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import random
2+
import time
3+
4+
import tinyio
5+
6+
7+
def test_dataloading():
8+
outs = []
9+
10+
def slow_transform(x):
11+
time.sleep(random.uniform(0.01, 0.02)) # slow I/O bound work
12+
return x * x
13+
14+
def main():
15+
iterator = range(100)
16+
pool = tinyio.ThreadPool(16)
17+
async_iterator = tinyio.AsCompleted({pool.run_in_thread(slow_transform, item) for item in iterator})
18+
while not async_iterator.done():
19+
out = yield async_iterator.get()
20+
outs.append(out)
21+
return outs
22+
23+
loop = tinyio.Loop()
24+
out = loop.run(main())
25+
assert set(out) == {x**2 for x in range(100)}
26+
assert out != [x**2 for x in range(100)] # test not in order. Very low chance of failing, should be fine!

tests/test_sync.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import time
2+
3+
import pytest
4+
import tinyio
5+
6+
7+
def test_semaphore():
8+
counter = 0
9+
10+
def _count(semaphore, i):
11+
nonlocal counter
12+
with (yield semaphore()):
13+
counter += 1
14+
if counter > 2:
15+
raise RuntimeError
16+
yield
17+
counter -= 1
18+
return i
19+
20+
def _run(value):
21+
semaphore = tinyio.Semaphore(value)
22+
out = yield [_count(semaphore, i) for i in range(50)]
23+
return out
24+
25+
loop = tinyio.Loop()
26+
assert loop.run(_run(2)) == list(range(50))
27+
with pytest.raises(RuntimeError):
28+
loop.run(_run(3))
29+
30+
31+
def test_lock():
32+
counter = 0
33+
34+
def _count(semaphore, i):
35+
nonlocal counter
36+
with (yield semaphore()):
37+
counter += 1
38+
if counter > 1:
39+
raise RuntimeError
40+
yield
41+
counter -= 1
42+
return i
43+
44+
def _run():
45+
semaphore = tinyio.Lock()
46+
out = yield [_count(semaphore, i) for i in range(50)]
47+
return out
48+
49+
loop = tinyio.Loop()
50+
assert loop.run(_run()) == list(range(50))
51+
52+
53+
def test_barrier():
54+
barrier = tinyio.Barrier(3)
55+
count = 0
56+
57+
def _foo():
58+
nonlocal count
59+
count += 1
60+
i = yield barrier.wait()
61+
time.sleep(0.1)
62+
assert count == 3
63+
return i
64+
65+
def _run():
66+
out = yield [_foo() for _ in range(3)]
67+
return out
68+
69+
loop = tinyio.Loop()
70+
assert set(loop.run(_run())) == {0, 1, 2}
71+
72+
73+
def test_event():
74+
event = tinyio.Event()
75+
done = False
76+
done2 = False
77+
78+
def _foo():
79+
nonlocal done
80+
assert event.is_set() is False
81+
yield event.wait()
82+
assert event.is_set() is True
83+
done = True
84+
85+
def _bar():
86+
nonlocal done2
87+
yield {_foo()}
88+
for _ in range(10):
89+
yield
90+
assert not done
91+
assert event.is_set() is False
92+
event.set()
93+
assert event.is_set()
94+
done2 = True
95+
96+
loop = tinyio.Loop()
97+
loop.run(_bar())
98+
assert done
99+
assert done2
100+
assert event.is_set()

0 commit comments

Comments
 (0)