Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
e426e1d
Added check for input type
patrick-kidger Jul 31, 2025
9de1e29
Refactored sync operations to use Event internally
patrick-kidger Jul 31, 2025
6919fa5
Improved check for semaphore re-entrancy
patrick-kidger Jul 31, 2025
7ad4200
Refactored run_in_thread to use Event internally
patrick-kidger Jul 31, 2025
4b27a71
Refactored loop internals to use an event-based system.
patrick-kidger Jul 31, 2025
5710a6e
Non-busy sleep.
patrick-kidger Jul 31, 2025
ccda02b
Make `AsCompleted` use events, plus many small bugfixes.
patrick-kidger Jul 31, 2025
f8b695a
Update README
patrick-kidger Jul 31, 2025
ac452ee
Make thread-pool test more robust.
patrick-kidger Jul 31, 2025
8ffef66
run_in_thread now handles simultaneous errors amongst multiple thread…
patrick-kidger Jul 31, 2025
0aff2fa
Topological sorter no longer using a comprehension, since it internal…
patrick-kidger Jul 31, 2025
4357211
Bugfix for empty gathers
patrick-kidger Aug 1, 2025
86a8de4
Added simple test for GC (we could still do more here)
patrick-kidger Aug 1, 2025
7b7dad8
The collection of _WaitingFor objects is now held on the _Wait insted…
patrick-kidger Aug 1, 2025
b8ce613
Refactored to use several _Wait methods in prep for timeouts
patrick-kidger Aug 1, 2025
faeca21
Added support Event.wait(timeout=...)
patrick-kidger Aug 1, 2025
4ec94ff
Added test for sleep times
patrick-kidger Aug 1, 2025
856e564
Rename test_basic -> test_core
patrick-kidger Aug 2, 2025
f76b51b
Handle multiple simultaneous timeouts fairly
patrick-kidger Aug 2, 2025
fdd6801
Several edge-case Event fixes
patrick-kidger Aug 2, 2025
aebfda9
Reorder contents of _core.py
patrick-kidger Aug 2, 2025
a089f10
Bump the number to, uh, 300 lines.
patrick-kidger Aug 2, 2025
7639b52
Made Event.wait an actual coroutine
patrick-kidger Aug 2, 2025
2118b72
Now robust to yielding already-finished coroutines.
patrick-kidger Aug 2, 2025
d80f066
Add a more serious GC check.
patrick-kidger Aug 2, 2025
9887129
version bump
patrick-kidger Jul 31, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 21 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<h1 align="center">tinyio</h1>
<h2 align="center">A tiny (~200 lines) event loop for Python</h2>
<h2 align="center">A tiny (~300 lines) event loop for Python</h2>

_Ever used `asyncio` and wished you hadn't?_

Expand Down Expand Up @@ -93,7 +93,7 @@ This gives every coroutine a chance to shut down gracefully. Debuggers like [`pa

### Batteries-included

We ship batteries-included with a collection of standard operations, all built on top of just the functionality you've already seen.
We ship batteries-included with the usual collection of standard operations.

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

Expand All @@ -104,7 +104,6 @@ tinyio.Barrier tinyio.timeout
tinyio.Event tinyio.TimeoutError
tinyio.Lock
```
None of these require special support from the event loop, they are all just simple implementations that you could have written yourself :)

---

Expand Down Expand Up @@ -138,7 +137,13 @@ None of these require special support from the event loop, they are all just sim

- `tinyio.Event()`

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.
This is a wrapper around a boolean flag, initialised with `False`.
This has the following methods:

- `.is_set()`: check the value of the flag.
- `.set()`: set the flag to `True`.
- `.clear()`: set the flag to `False`.
- `.wait()`, which is a coroutine you can `yield` on. This will unblock if the internal flag is `True`. (Typically this is accomplished by calling `.set()` from another coroutine or from a thread.)

---

Expand Down Expand Up @@ -166,7 +171,7 @@ None of these require special support from the event loop, they are all just sim

- `tinyio.timeout(coro, timeout_in_seconds)`

This is a coroutine you can `yield` on, used as `output, success = yield tinyio.timeout(coro, timeout_in_seconds)`.
This is a coroutine you can `yield` on, used as `output, success = yield tinyio.timeout(coro, timeout_in_seconds)`.

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.

Expand Down Expand Up @@ -201,18 +206,21 @@ The reason is that `await` does not offer a suspension point to an event loop (i
You can distinguish it from a normal Python function by putting `if False: yield` somewhere inside its body. Another common trick is to put a `yield` statement after the final `return` statement. Bit ugly but oh well.
</details>

<details>
<summary>Any funny business to know around loops?</summary>
<br>

The output of each coroutine is stored on the `Loop()` class. If you attempt to run a previously-ran coroutine in a new `Loop()` then they will be treated as just returning `None`, which is probably not what you want.
</details>

<details>
<summary>vs <code>asyncio</code> or <code>trio</code>?.</summary>
<br>

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.

Nonetheless you'll definitely still want one of the above if you need anything fancy. If you don't, and you really really want simple error semantics, then maybe `tinyio` is for you instead. (In particular `trio` will be a better choice if you still need the event loop when cleaning up from errors; in contrast `tinyio` does not allow scheduling work back on the event loop at that time.)
`tinyio` has the following unique features, and as such may be the right choice if any of the following are must-haves for you:

- the propagation of errors to/from threads;
- no one-loop-per-thread rule;
- simple+robust error semantics (crash the whole loop if anything goes wrong);
- tiny, hackable, codebase.

However conversely, `tinyio` does not offer the ability to schedule work on the event loop whilst cleaning up from errors.

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. :)

</details>
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ name = "tinyio"
readme = "README.md"
requires-python = ">=3.11"
urls = {repository = "https://github.com/patrick-kidger/tinyio"}
version = "0.1.3"
version = "0.1.4"

[project.optional-dependencies]
dev = ["pre-commit", "pytest"]
Expand All @@ -38,7 +38,7 @@ src = []

[tool.ruff.lint]
fixable = ["I001", "F401", "UP"]
ignore = ["E402", "E721", "E731", "E741", "F722"]
ignore = ["E402", "E721", "E731", "E741", "F722", "UP038"]
select = ["E", "F", "I001", "UP"]

[tool.ruff.lint.flake8-import-conventions.extend-aliases]
Expand Down
64 changes: 64 additions & 0 deletions tests/test_background.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,43 @@
import pytest
import tinyio


def _sleep(x):
yield tinyio.sleep(x)
return x


def test_as_completed():
def _run():
iterator = tinyio.AsCompleted({_sleep(0.3), _sleep(0.1), _sleep(0.2)})
outs = []
while not iterator.done():
x = yield iterator.get()
outs.append(x)
return outs

loop = tinyio.Loop()
assert loop.run(_run()) == [0.1, 0.2, 0.3]


def test_as_completed_out_of_order():
def _run():
iterator = tinyio.AsCompleted({_sleep(0.3), _sleep(0.1), _sleep(0.2)})
get1 = iterator.get()
get2 = iterator.get()
get3 = iterator.get()
with pytest.raises(RuntimeError, match="which is greater than the number of coroutines"):
iterator.get()
assert iterator.done()
out3 = yield get3
out2 = yield get2
out1 = yield get1
return [out1, out2, out3]

loop = tinyio.Loop()
assert loop.run(_run()) == [0.1, 0.2, 0.3]


def _block(event1: tinyio.Event, event2: tinyio.Event, out):
yield event1.wait()
event2.set()
Expand All @@ -19,6 +56,8 @@ def _test_done_callback():
assert len(out) == 0
event1.set()
yield event3.wait()
for _ in range(20):
yield
assert out == [2, 1]


Expand All @@ -27,6 +66,31 @@ def test_done_callback():
loop.run(_test_done_callback())


def test_yield_on_wrapped_coroutine():
callbacked = False
event = tinyio.Event()

def _callback(_):
nonlocal callbacked
callbacked = True
event.set()

def _foo():
yield
return 3

foo = _foo()

def _bar():
yield {tinyio.add_done_callback(foo, _callback)}
yield event.wait()
out = yield foo
return out

loop = tinyio.Loop()
assert loop.run(_bar()) == 3


# To reinstate if we ever reintroduce error callbacks.


Expand Down
208 changes: 0 additions & 208 deletions tests/test_basic.py

This file was deleted.

Loading