Skip to content
10 changes: 8 additions & 2 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 @@ -137,7 +137,13 @@ tinyio.Lock

- `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
163 changes: 163 additions & 0 deletions tests/test_basic.py → tests/test_core.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import gc
import threading
import time
from typing import Any

import pytest
import tinyio
Expand Down Expand Up @@ -276,3 +278,164 @@ def _gc(x: int) -> tinyio.Coro[tuple[int, int]]:
assert loop.run(coro) == (5, 5)
gc.collect()
assert set(loop._results.keys()) == {coro}


def test_event_fairness():
"""This checks that once one event unblocks, that we don't just keep chasing all the stuff downstream of that event,
i.e. that we do schedule work from any other event that has finished.
"""
outs = []

def f():
yield tinyio.Event().wait(0)
outs.append(1)
for _ in range(20):
yield
outs.append(2)

def g():
yield [f(), f()]

loop = tinyio.Loop()
loop.run(g())
assert outs == [1, 1, 2, 2]


def test_event_fairness2():
event1 = tinyio.Event()
outs = []

def f():
yield event1.wait(0)
outs.append(1)

def g():
yield {f()}
for _ in range(20):
yield
outs.append(2)

loop = tinyio.Loop()
loop.run(g())
assert outs == [1, 2]


def test_simultaneous_set():
event = tinyio.Event()

def f():
for _ in range(20):
yield
yield [tinyio.run_in_thread(event.set) for _ in range(100)]

def g():
yield event.wait()

def h():
yield [g(), f()]

loop = tinyio.Loop()
loop.run(h())


def test_timeout_then_set():
event1 = tinyio.Event()
event2 = tinyio.Event()

def f():
yield [event1.wait(0), event2.wait()]

def g():
yield {f()}
for _ in range(20):
yield
event1.set()
for _ in range(20):
yield
event2.set()
return 3

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


def test_set_then_timeout():
event1 = tinyio.Event()
event2 = tinyio.Event()

def f():
event1.set()
yield [event1.wait(0), event2.wait()]

def g():
yield {f()}
for _ in range(20):
yield
event2.set()
return 3

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


def test_set_then_timeout_then_clear():
event1 = tinyio.Event()
event2 = tinyio.Event()

def f():
event1.set()
yield [event1.wait(0), event2.wait()]

def g():
yield {f()}
for _ in range(20):
yield
event1.clear()
event2.set()
return 3

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


def test_set_then_timeout_then_clear_then_set():
event1 = tinyio.Event()
event2 = tinyio.Event()

def f():
event1.set()
yield [event1.wait(0), event2.wait()]

def g():
yield {f()}
for _ in range(20):
yield
event1.clear()
event2.set()
event1.set()
return 3

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


def test_timeout_as_part_of_group_and_only_coroutine():
event1 = tinyio.Event()
event2 = tinyio.Event()
wait: Any = event1.wait(0)
wait2 = event2.wait()

def f():
yield [wait, wait2]
return 3

def set2():
while wait.state != tinyio._core._WaitState.NOTIFIED_TIMEOUT:
pass
time.sleep(0.1)
event2.set()

t = threading.Thread(target=set2)
t.start()
loop = tinyio.Loop()
assert loop.run(f()) == 3
102 changes: 101 additions & 1 deletion tests/test_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ def test_event_run(is_set: bool):


@pytest.mark.parametrize("is_set", (False, True))
def test_event_double_wait(is_set: bool):
def test_event_repeated_wait(is_set: bool):
event = tinyio.Event()
if is_set:
event.set()
Expand Down Expand Up @@ -184,3 +184,103 @@ def _foo():

loop = tinyio.Loop()
loop.run(_foo())


@pytest.mark.parametrize("is_set", (False, True))
def test_event_simultaneous_repeated_wait(is_set: bool):
event = tinyio.Event()
if is_set:
event.set()

def foo():
wait = event.wait()
if not is_set:
t = threading.Timer(0.1, lambda: event.set())
t.start()
yield [wait, wait]

loop = tinyio.Loop()
with pytest.raises(RuntimeError, match=re.escape("Do not yield the same `event.wait()` multiple times")):
loop.run(foo())


def test_event_clear_not_strict():
event = tinyio.Event()
event.set()
event.clear()
assert not event.is_set()

out = []

def foo():
yield event.wait()
out.append(2)

def bar():
yield
out.append(1)
event.set()
yield
# Even though we `clear()` the event again afterwards, both `foo()` still unblock.
event.clear()
out.append(3)

def baz():
yield [foo(), foo(), bar()]

loop = tinyio.Loop()
loop.run(baz())
assert out == [1, 2, 2, 3]


class _Semaphore:
def __init__(self, value):
self.value = value
self.event = tinyio.Event()
self.event.set()

def __call__(self):
while True:
yield self.event.wait()
if self.event.is_set():
break
assert self.value > 0
self.value -= 1
if self.value == 0:
self.event.clear()
return _closing(self)


@contextlib.contextmanager
def _closing(semaphore):
try:
yield
finally:
semaphore.value += 1
semaphore.event.set()


def test_alternate_semaphore():
"""This test is useful as it makes use of `Event.clear()`."""

counter = 0

def _count(semaphore, i):
nonlocal counter
with (yield semaphore()):
counter += 1
if counter > 2:
raise RuntimeError
yield
counter -= 1
return i

def _run(value):
semaphore = _Semaphore(value)
out = yield [_count(semaphore, i) for i in range(50)]
return out

loop = tinyio.Loop()
assert loop.run(_run(2)) == list(range(50))
with pytest.raises(RuntimeError):
loop.run(_run(3))
21 changes: 21 additions & 0 deletions tests/test_time.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,27 @@
import time

import tinyio


def test_sleep():
outs = []

def f():
start = time.monotonic()
yield [tinyio.sleep(0.05), tinyio.sleep(0.1)]
actual_duration = time.monotonic() - start
# Note that these are pretty inaccurate tolerances! This is about what we get with `asyncio` too.
# The reason for this seems to be the accuracy in the `threading.Event.wait()` that we bottom out in. If we need
# greater resolution than this then we could do that by using a busy-loop for the last 1e-2 seconds.
success = 0.09 < actual_duration < 0.11
outs.append(success)

loop = tinyio.Loop()
for _ in range(5):
loop.run(f())
assert sum(outs) >= 4 # We allow one failure, to decrease flakiness.


def _sleep(x):
yield tinyio.sleep(x)
return 3
Expand Down
Loading