Skip to main content

A tiny event loop for Python.

Project description

tinyio

A tiny (~200 lines) event loop for Python

Ever used asyncio and wished you hadn't?

tinyio is a dead-simple event loop for Python, born out of my frustration with trying to get robust error handling with asyncio. (I'm not the only one running into its sharp corners: link1, link2.)

This is an alternative for the simple use-cases, where you just need an event loop, and want to crash the whole thing if anything goes wrong. (Raising an exception in every coroutine so it can clean up its resources.)

import tinyio

def slow_add_one(x: int):
    yield tinyio.sleep(1)
    return x + 1

def foo():
    four, five = yield [slow_add_one(3), slow_add_one(4)]
    return four, five

loop = tinyio.Loop()
out = loop.run(foo())
assert out == (4, 5)
  • 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).
  • An error in one coroutine will cancel all coroutines across the entire event loop.
    • If the erroring coroutine is sequentially depended on by a chain of other coroutines, then we likewise chain their tracebacks for easier debugging.
    • Errors even propagate to and from synchronous operations ran in threads.
  • Can nest tinyio loops inside each other, none of this one-per-thread business.
  • Ludiciously simple. No need for futures, tasks, etc. Here's the full API:
    tinyio.Loop
    tinyio.CancelledError
    tinyio.sleep
    tinyio.run_in_thread
    

Installation

pip install tinyio

Documentation

Loops

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.

Yielding

You can yield three possible things:

  • yield: yield nothing, this just pauses and gives other coroutines a chance to run.
  • yield coro: wait on a single coroutine, in which case we'll resume with the output of that coroutine once it is available.
  • yield [coro1, coro2, coro3]: wait on multiple coroutines by putting them in a list, and resume with a list of outputs once all have completed. This is what asyncio calls a 'gather' or 'TaskGroup', and what trio calls a 'nursery'.

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.

Threading

Synchronous functions can be ran in threads using tinyio.run_in_thread, which returns a coroutine you can yield on:

import time, tinyio

def slow_blocking_add_one(x: int) -> int:
    time.sleep(1)
    return x + 1

def foo(x: int):
    out = yield [tinyio.run_in_thread(slow_blocking_add_one, x) for _ in range(3)]
    return out

loop = tinyio.Loop()
out = loop.run(foo(x=1))  # runs in one second, not three
assert out == [2, 2, 2]

Error propagation

If any coroutine raises an error, then:

  1. All coroutines across the entire loop will have tinyio.CancelledError raised in them (from whatever yield point they are currently waiting at).
  2. Any functions ran in threads via tinyio.run_in_thread will also have tinyio.CancelledError raised in the thread.
  3. All errors are collected and raised in a BaseExceptionGroup. The original error will be the first in this group.

This gives every coroutine a chance to shut down gracefully. Debuggers like 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.

FAQ

Why yield -- why not await like is normally seen for coroutines?

The reason is that await does not offer a suspension point to an event loop (it just calls __await__ and maybe that offers a suspension point), so if we wanted to use that syntax then we'd need to replace yield coro with something like await tinyio.Task(coro). The traditional syntax is not worth the extra class.

I have a function I want to be a coroutine, but it has zero yield statements, so it is just a normal function?

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.

Any funny business to know around loops?

(A) It is safe to call .run multiple times from the same loop, e.g. if you have multiple coroutines that are awaiting on shared internal coroutines. (B) 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 you'll probably get an error.

vs asyncio or trio?.

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 seems nice but still has an odd one-loop-per-thread rule. This inspired me to try writing my own.

Honestly you probably 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.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

tinyio-0.1.0.tar.gz (13.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

tinyio-0.1.0-py3-none-any.whl (17.6 kB view details)

Uploaded Python 3

File details

Details for the file tinyio-0.1.0.tar.gz.

File metadata

  • Download URL: tinyio-0.1.0.tar.gz
  • Upload date:
  • Size: 13.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.11.9

File hashes

Hashes for tinyio-0.1.0.tar.gz
Algorithm Hash digest
SHA256 6a4d8857975f6fd76c00581ce3f65519c77f308b44c6228335b966881b2e580e
MD5 3b7697dcc8e24b263106d255f25579bb
BLAKE2b-256 ceb2f365fe15f882c10ce38ad67b2afe593c51af062cd1966cc827da6ad3e006

See more details on using hashes here.

File details

Details for the file tinyio-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: tinyio-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 17.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.11.9

File hashes

Hashes for tinyio-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 f4e0f3148be1aac9ba778ab26eb8db0cd5455c3140831325effd341e529a3eb9
MD5 5b67faabf851714503690f45cf2fc2b1
BLAKE2b-256 c6e09a22a727c8c78972b4eb18cbe1fe145527d0a2e9d90a6c1a5af64f79fe9c

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page