Skip to content

Commit 870b407

Browse files
doc update, in particular including isolation
1 parent cfe7550 commit 870b407

4 files changed

Lines changed: 108 additions & 74 deletions

File tree

README.md

Lines changed: 90 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,9 @@ 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
94+
### Synchronisation
9595

96-
We ship batteries-included with the usual collection of standard operations.
96+
We ship batteries-included with the usual collection of standard operations for synchronisation.
9797

9898
<details><summary>Click to expand</summary>
9999

@@ -181,6 +181,73 @@ tinyio.Lock tinyio.TimeoutError
181181

182182
</details>
183183

184+
### Asynchronous context managers
185+
186+
<details><summary>Click to expand</summary>
187+
188+
You can use the following pattern to implement context managers with asynchronous creation:
189+
190+
```python
191+
import contextlib
192+
import tinyio
193+
194+
def my_context_manager(x):
195+
print("Initialising...")
196+
yield tinyio.sleep(1)
197+
print("Initialised")
198+
return make_context_manager(x)
199+
200+
@contextlib.contextmanager
201+
def make_context_manager(x):
202+
try:
203+
yield x
204+
finally:
205+
print("Cleaning up")
206+
207+
def my_coro():
208+
with (yield my_context_manager(x=5)) as val:
209+
print(f"Got val {val}")
210+
211+
tinyio.Loop().run(my_coro())
212+
```
213+
214+
This isn't anything fancier than just using a coroutine that returns a regular `with`-compatible context manager. See `tinyio.Semaphore` for an example of this pattern.
215+
216+
</details>
217+
218+
### Asynchronous iterators
219+
220+
<details><summary>Click to expand</summary>
221+
222+
You can use the following pattern to implement asychronous iterators:
223+
224+
```python
225+
import tinyio
226+
227+
def slow_range(x): # this function is an iterator-of-coroutines
228+
for i in range(x):
229+
yield slow_range_i(i) # this `yield` statement is seen by the `for` loop
230+
231+
def slow_range_i(i): # this function is a coroutine
232+
yield tinyio.sleep(1) # this `yield` statement is seen by the `tinyio.Loop()`
233+
return i
234+
235+
def my_coro():
236+
for x in slow_range(5):
237+
x = yield x
238+
print(f"Got {x}")
239+
240+
tinyio.Loop().run(my_coro())
241+
```
242+
243+
Here we just have `yield` being used in a couple of different ways that you're already used to:
244+
- as a regular Python generator/iterator;
245+
- as a `tinyio` coroutine.
246+
247+
For an example of this, see `tinyio.as_completed`.
248+
249+
</details>
250+
184251
### Integration with `asyncio` and `trio`
185252

186253
We have support for putting `trio` event loops within `asyncio`/`trio` event loops, or vice-versa.
@@ -275,71 +342,28 @@ tinyio.from_asyncio tinyio.from_trio
275342

276343
</details>
277344

278-
### Asynchronous context managers
279-
280-
<details><summary>Click to expand</summary>
281-
282-
You can use the following pattern to implement context managers with asynchronous creation:
283-
284-
```python
285-
import contextlib
286-
import tinyio
287-
288-
def my_context_manager(x):
289-
print("Initialising...")
290-
yield tinyio.sleep(1)
291-
print("Initialised")
292-
return make_context_manager(x)
293-
294-
@contextlib.contextmanager
295-
def make_context_manager(x):
296-
try:
297-
yield x
298-
finally:
299-
print("Cleaning up")
300-
301-
def my_coro():
302-
with (yield my_context_manager(x=5)) as val:
303-
print(f"Got val {val}")
304-
305-
tinyio.Loop().run(my_coro())
306-
```
307-
308-
This isn't anything fancier than just using a coroutine that returns a regular `with`-compatible context manager. See `tinyio.Semaphore` for an example of this pattern.
309-
310-
</details>
311-
312-
### Asynchronous iterators
345+
### Advanced topic: isolating coroutines without crashing the others
313346

314347
<details><summary>Click to expand</summary>
315348

316-
You can use the following pattern to implement asychronous iterators:
349+
If you would like to run a coroutine (and transitively, all the coroutines it `yield`s) without crashing the entire event loop, then you can use `tinyio.isolate`. This will run the coroutine in a nested `tinyio.Loop` – so that it crashing will only affect everything in that nested loop – and then return the result or the error.
317350

318351
```python
319-
import tinyio
320-
321-
def slow_range(x): # this function is an iterator-of-coroutines
322-
for i in range(x):
323-
yield slow_range_i(i) # this `yield` statement is seen by the `for` loop
324-
325-
def slow_range_i(i): # this function is a coroutine
326-
yield tinyio.sleep(1) # this `yield` statement is seen by the `tinyio.Loop()`
327-
return i
328-
329-
def my_coro():
330-
for x in slow_range(5):
331-
x = yield x
332-
print(f"Got {x}")
333-
334-
tinyio.Loop().run(my_coro())
352+
def some_coroutine(x):
353+
if x == 3:
354+
raise ValueError("Oh no, a bug!")
355+
yield
356+
357+
def someone_elses_buggy_code():
358+
yield [some_coroutine(x=3), another_coroutine()]
359+
360+
def main():
361+
app = someone_elses_buggy_code()
362+
result_or_error, success = yield tinyio.isolate(app)
363+
# at this point then `someone_elses_buggy_code`, `some_coroutine` and
364+
#`another_coroutine` have been cancelled, but `main` is still running.
335365
```
336366

337-
Here we just have `yield` being used in a couple of different ways that you're already used to:
338-
- as a regular Python generator/iterator;
339-
- as a `tinyio` coroutine.
340-
341-
For an example of this, see `tinyio.as_completed`.
342-
343367
</details>
344368

345369
## FAQ
@@ -359,7 +383,7 @@ You can distinguish it from a normal Python function by putting `if False: yield
359383
</details>
360384

361385
<details>
362-
<summary>vs <code>asyncio</code> or <code>trio</code>?.</summary>
386+
<summary>vs <code>asyncio</code> or <code>trio</code>?</summary>
363387
<br>
364388

365389
I wasted a *lot* of time trying to get correct error propagation with `asyncio`, trying to reason whether my tasks would be cleaned up correctly or not (edge-triggered vs level-triggered etc etc). `trio` is excellent but still has a one-loop-per-thread rule, and doesn't propagate cancellations to/from threads. These points inspired me to try writing my own.
@@ -371,8 +395,11 @@ I wasted a *lot* of time trying to get correct error propagation with `asyncio`,
371395
- simple+robust error semantics (crash the whole loop if anything goes wrong);
372396
- tiny, hackable, codebase.
373397

374-
However conversely, `tinyio` does not offer the ability to schedule work on the event loop whilst cleaning up from errors.
398+
Conversely, at least right now we don't ship batteries-included with a few things:
399+
400+
- asynchronously launching subprocesses / making network requests / accessing the file system (in all cases use `run_in_thread` instead);
401+
- scheduling work on the event loop whilst cleaning up from errors.
375402

376-
If none of the bullet points are must-haves for you, or if needing the event loop during cleanup is a dealbreaker, then either `trio` or `asyncio` are likely to be better choices. :)
403+
If none of the bullet points are must-haves for you, or if any of its limitations are dealbreakers, then either `trio` or `asyncio` are likely to be better choices. :)
377404

378405
</details>

tests/test_core.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -576,7 +576,7 @@ def coro():
576576
yield
577577

578578
def foo():
579-
with tinyio.Loop().runtime(coro(), exception_group=None) as gen:
579+
with tinyio.Loop().runtime(coro(), exception_group=None):
580580
return 3
581581

582582
with pytest.raises(RuntimeError, match="only partially completed"):

tests/test_isolate.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,9 +165,16 @@ def bar():
165165

166166

167167
def test_isolate_respects_cancellation():
168+
foo_cancelled = False
169+
168170
def foo():
169-
while True:
170-
yield
171+
nonlocal foo_cancelled
172+
try:
173+
while True:
174+
yield
175+
except tinyio.CancelledError:
176+
foo_cancelled = True
177+
raise
171178

172179
def bar():
173180
yield {tinyio.isolate(foo())}
@@ -177,3 +184,4 @@ def bar():
177184
warnings.simplefilter("error")
178185
with pytest.raises(RuntimeError, match="Kaboom"):
179186
tinyio.Loop().run(bar())
187+
assert foo_cancelled

tinyio/_isolate.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@ def your_coroutine():
4242
pipe = []
4343
schedule = Event()
4444
done = Event()
45-
failed = Event()
4645

4746
def put_on_old_loop():
4847
# Don't actually `yield coro` until `put_on_new_loop` has started.
@@ -51,21 +50,21 @@ def put_on_old_loop():
5150
try:
5251
out = yield coro
5352
except BaseException as e:
54-
pipe.append(e)
55-
failed.set()
56-
done.set()
53+
pipe.append((e, False))
5754
raise
5855
else:
59-
pipe.append(out)
56+
pipe.append((out,True))
57+
finally:
6058
done.set()
6159

6260
def put_on_new_loop() -> Coro[_T]:
6361
schedule.set()
6462
yield done.wait()
65-
if failed.is_set():
66-
raise pipe[0]
63+
[[out, success]] = pipe
64+
if success:
65+
return out
6766
else:
68-
return pipe[0]
67+
raise out
6968

7069
yield {put_on_old_loop()}
7170
return put_on_new_loop()

0 commit comments

Comments
 (0)