Skip to main content

Low-latency async serial I/O for Python, built on AnyIO.

Project description

anyserial

Async-native serial I/O for Python, built on AnyIO.

CI PyPI version Python versions License: MIT

[!WARNING] Alpha. The API is not yet stable and may change between minor versions. See DESIGN.md for the architecture and design rationale.

Overview

anyserial is a ground-up async serial transport. It runs on top of AnyIO, so the same code works under asyncio, uvloop, or trio without changes, and it exposes a thin blocking wrapper for scripts and test benches that don't want an event loop.

The focus is low-latency, predictable I/O against real hardware:

  • Async-native. Readiness-driven I/O on nonblocking file descriptors — no worker threads on the POSIX hot path.
  • POSIX first-class. Linux, macOS, and BSD; Windows via IOCP (trio and ProactorEventLoop).
  • Explicit capabilities. Features that a given platform or adapter can't support fail loudly at configure time — no silent emulation.
  • Raw bytes only. Framing belongs in user code or downstream libraries; compose with anyio.streams.buffered.BufferedByteStream for line- or delimiter-based reads.
  • Immutable, typed config. Frozen dataclasses, strict type checking, full PEP 561 support.
  • Runtime reconfiguration. Change baud, parity, or flow control on an open port without reopening.
  • RS-485, low-latency mode, custom baud rates where the platform exposes them.

Requirements

  • Python 3.13 or 3.14
  • Linux, macOS, BSD, or Windows
  • anyio >= 4.13

Installation

uv add anyserial
# or
pip install anyserial

Optional extras:

uv add "anyserial[uvloop]"              # Linux/macOS uvloop event loop
uv add "anyserial[winloop]"             # Windows winloop event loop
uv add "anyserial[trio]"                # trio runtime
uv add "anyserial[discovery-pyudev]"    # richer Linux port discovery
uv add "anyserial[discovery-pyserial]"  # pyserial-based discovery fallback

Usage

Async

import anyio
from anyserial import SerialConfig, open_serial_port


async def main() -> None:
    config = SerialConfig(baudrate=115_200)
    async with await open_serial_port("/dev/ttyUSB0", config) as port:
        await port.send(b"AT\r\n")
        reply = await port.receive(64)
        print(reply)


anyio.run(main)

receive(max_bytes) returns as soon as any bytes are available; a clean EOF raises SerialDisconnectedError. send handles partial writes internally. Use an AnyIO cancel scope to bound a read:

with anyio.move_on_after(1.0):
    reply = await port.receive(64)

Sync

from anyserial.sync import SerialPort

with SerialPort.open("/dev/ttyUSB0", baudrate=115_200) as port:
    port.send(b"ping\n")
    reply = port.receive(1024, timeout=1.0)

The sync wrapper is backed by a process-wide anyio.from_thread.BlockingPortalProvider; every blocking call accepts an optional timeout=. Each call pays a one-time portal hop (~tens to hundreds of µs on a modern laptop) — fine for setup and occasional I/O, visible on tight request/response loops. Prefer async for those; see docs/sync.md.

Line-framed protocols

For protocols terminated by \n, \r, or any fixed delimiter, wrap the port in AnyIO's BufferedByteStream. It handles partial reads across the delimiter for you, delegates send to the underlying port, and has no measurable overhead versus a hand-rolled loop:

from anyio.streams.buffered import BufferedByteStream

async with await open_serial_port("/dev/ttyUSB0", config) as port:
    buffered = BufferedByteStream(port)
    await buffered.send(b"AT\r")
    line = await buffered.receive_until(b"\r", max_bytes=512)

Fan-out: reading from many devices at once

One event loop handles N ports concurrently, no thread-per-port. This is where anyserial pulls ahead of sync libraries — see the hardware case study for numbers (6× faster than thread-per-port pyserial at N=16 on pty-backed peers).

import anyio
from anyserial import SerialConfig, open_serial_port


async def poll_one(path: str, results: dict[str, bytes]) -> None:
    async with await open_serial_port(path, SerialConfig(baudrate=115_200)) as port:
        await port.send(b"A\r")
        results[path] = await port.receive(256)


async def main() -> None:
    paths = ["/dev/ttyUSB0", "/dev/ttyUSB1", "/dev/ttyUSB2"]
    results: dict[str, bytes] = {}
    async with anyio.create_task_group() as tg:
        for p in paths:
            tg.start_soon(poll_one, p, results)
    for path, frame in results.items():
        print(path, frame)


anyio.run(main)

Discovery

from anyserial import find_serial_port, list_serial_ports

for info in await list_serial_ports():
    print(info.device, info.vid, info.pid, info.serial_number)

ftdi = await find_serial_port(vid=0x0403, pid=0x6001)

Testing without hardware

from anyserial.testing import serial_port_pair

a, b = serial_port_pair()
await a.send(b"hello")
assert await b.receive(5) == b"hello"

MockBackend and FaultPlan (also in anyserial.testing) cover fault-injection scenarios.

Documentation

Full documentation lives at https://graysonbellamy.github.io/anyserial/. Starting points:

Contributing

Issues and PRs are welcome. To get a local checkout running:

git clone https://github.com/GraysonBellamy/anyserial
cd anyserial
uv sync --all-extras
uv run pre-commit install

Before opening a PR:

uv run pytest
uv run ruff check
uv run ruff format --check
uv run mypy

Hardware-dependent tests are opt-in via pytest -m hardware with ANYSERIAL_TEST_PORT set; see docs/hardware-testing.md.

License

MIT. See LICENSE.

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

anyserial-0.1.2.tar.gz (233.8 kB view details)

Uploaded Source

Built Distribution

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

anyserial-0.1.2-py3-none-any.whl (131.9 kB view details)

Uploaded Python 3

File details

Details for the file anyserial-0.1.2.tar.gz.

File metadata

  • Download URL: anyserial-0.1.2.tar.gz
  • Upload date:
  • Size: 233.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for anyserial-0.1.2.tar.gz
Algorithm Hash digest
SHA256 e17277a1ff894da4f8a7f9f03db68d47cb2035b373846651532e48d747ae5fac
MD5 df16752e504129429c139272e4ccb255
BLAKE2b-256 c4dbfd539426d89b0b4b0e9fe3f3636b67019f200356e2b091356d297e4f2adb

See more details on using hashes here.

Provenance

The following attestation bundles were made for anyserial-0.1.2.tar.gz:

Publisher: publish.yml on GraysonBellamy/anyserial

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file anyserial-0.1.2-py3-none-any.whl.

File metadata

  • Download URL: anyserial-0.1.2-py3-none-any.whl
  • Upload date:
  • Size: 131.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for anyserial-0.1.2-py3-none-any.whl
Algorithm Hash digest
SHA256 81dc1a061a0aecf42395c8c073505e269c077824e80812026a82b25e719b4989
MD5 933344437a815f439376e1ef7db7e3c5
BLAKE2b-256 a1d98ce9148e7b3a58dac17461b35a213939af3e4eaf1305cbb85a813a572b69

See more details on using hashes here.

Provenance

The following attestation bundles were made for anyserial-0.1.2-py3-none-any.whl:

Publisher: publish.yml on GraysonBellamy/anyserial

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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