From 069b2273c878067d47196105e647ccb00c593825 Mon Sep 17 00:00:00 2001 From: Pavel Revak Date: Fri, 2 Oct 2026 07:24:10 +0200 Subject: [PATCH] python-stdlib/selectors: Add selectors module based on select.poll. This adds a CPython compatible `selectors` module implemented as a thin layer over MicroPython's `select.poll()`. It provides EVENT_READ, EVENT_WRITE, SelectorKey, BaseSelector, PollSelector and DefaultSelector with register(), unregister(), modify(), select(), close(), get_key(), get_map() and context manager support. File objects are tracked by identity rather than by file descriptor, because sockets on bare-metal ports have no fileno(). SelectorKey.fd is the fileno() when available, otherwise -1. The tests also pass against CPython's stdlib selectors module. Signed-off-by: Pavel Revak --- python-stdlib/selectors/README.md | 83 +++++++++ python-stdlib/selectors/manifest.py | 3 + python-stdlib/selectors/selectors.py | 190 ++++++++++++++++++++ python-stdlib/selectors/test_selectors.py | 205 ++++++++++++++++++++++ tools/ci.sh | 1 + 5 files changed, 482 insertions(+) create mode 100644 python-stdlib/selectors/README.md create mode 100644 python-stdlib/selectors/manifest.py create mode 100644 python-stdlib/selectors/selectors.py create mode 100644 python-stdlib/selectors/test_selectors.py diff --git a/python-stdlib/selectors/README.md b/python-stdlib/selectors/README.md new file mode 100644 index 000000000..a9e664ac1 --- /dev/null +++ b/python-stdlib/selectors/README.md @@ -0,0 +1,83 @@ +# selectors + +This library implements a subset of CPython's +[`selectors`](https://docs.python.org/3/library/selectors.html) module as a +thin layer over MicroPython's built-in +[`select.poll()`](https://docs.micropython.org/en/latest/library/select.html). +It lets code written for CPython's high-level I/O multiplexing API run +unchanged on MicroPython, on both the unix port and bare-metal ports. + +## Example + +```python +import selectors +import socket + +sel = selectors.DefaultSelector() + +server = socket.socket() +server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +server.bind(socket.getaddrinfo("0.0.0.0", 8080)[0][-1]) +server.listen(5) +sel.register(server, selectors.EVENT_READ, "accept") + +while True: + for key, events in sel.select(timeout=1): + if key.data == "accept": + conn, addr = server.accept() + conn.setblocking(False) + sel.register(conn, selectors.EVENT_READ, "client") + else: + data = key.fileobj.recv(512) + if data: + key.fileobj.send(data) + else: + sel.unregister(key.fileobj) + key.fileobj.close() +``` + +## Supported API + +- `EVENT_READ`, `EVENT_WRITE` +- `SelectorKey(fileobj, fd, events, data)` named tuple +- `BaseSelector` with `register()`, `unregister()`, `modify()`, `select()`, + `close()`, `get_key()`, `get_map()` and context manager support +- `PollSelector`, and `DefaultSelector` as an alias for it + +Error handling follows CPython: invalid event masks or file objects raise +`ValueError`, registering an object twice or using an unregistered object +raises `KeyError`, and `get_key()` on a closed selector raises `RuntimeError`. + +`select(timeout)` accepts `None` (block), a value `<= 0` (poll without +waiting) or a timeout in seconds (float allowed), and returns a list of +`(key, events)` tuples. Error and hang-up conditions are reported as both +read and write readiness, masked by the events the object was registered for, +the same way as CPython's `PollSelector` does. + +## Differences from CPython + +- File objects are tracked by identity, not by file descriptor, because + sockets on bare-metal ports have no `fileno()`. Any object supported by + `select.poll()` can be registered, e.g. sockets, SSL sockets, UARTs or + `sys.stdin`. On the unix port an integer file descriptor can be registered + as well. +- `SelectorKey.fd` is the result of `fileno()` when the object provides it, + otherwise `-1`. +- `get_map()` returns a plain `dict` keyed by the registered file object, so + it cannot be indexed by file descriptor. Treat it as read-only. +- Two different objects sharing the same file descriptor are not detected as + a duplicate registration. +- Only `PollSelector` is provided; `SelectSelector`, `EpollSelector`, + `DevpollSelector` and `KqueueSelector` are not available. + +## Installation + +Use `mip` via `mpremote`: + +```bash +> mpremote mip install selectors +``` + +See [Package +management](https://docs.micropython.org/en/latest/reference/packages.html) for +more details on using `mip` and `mpremote`. diff --git a/python-stdlib/selectors/manifest.py b/python-stdlib/selectors/manifest.py new file mode 100644 index 000000000..eb96dbaab --- /dev/null +++ b/python-stdlib/selectors/manifest.py @@ -0,0 +1,3 @@ +metadata(version="0.1.0", description="CPython compatible selectors module based on select.poll.") + +module("selectors.py") diff --git a/python-stdlib/selectors/selectors.py b/python-stdlib/selectors/selectors.py new file mode 100644 index 000000000..20a7ea0f5 --- /dev/null +++ b/python-stdlib/selectors/selectors.py @@ -0,0 +1,190 @@ +"""selectors - CPython compatible selectors module for MicroPython + +Thin layer over MicroPython's select.poll(), usable like the CPython module: + + import selectors + sel = selectors.DefaultSelector() + sel.register(sock, selectors.EVENT_READ, data) + for key, mask in sel.select(timeout): + ... + +Differences from CPython: +- objects are keyed by identity, not by file descriptor (bare-metal + sockets have no fileno()); SelectorKey.fd is fileno() when available, + else -1 +- get_map() returns a plain dict keyed by file object (treat it as + read-only) +- only PollSelector is provided, DefaultSelector is an alias for it + +MIT license; Copyright (c) 2026 Pavel Revak +""" + +import select as _select +from collections import namedtuple as _namedtuple + +EVENT_READ = 1 << 0 +EVENT_WRITE = 1 << 1 + +_EVENTS_ALL = EVENT_READ | EVENT_WRITE +_POLLIN = _select.POLLIN +_POLLOUT = _select.POLLOUT + +SelectorKey = _namedtuple("SelectorKey", ["fileobj", "fd", "events", "data"]) + + +def _fileobj_to_fd(fileobj): + """Return the file descriptor of fileobj, -1 if it has none""" + if isinstance(fileobj, int): + fd = fileobj + else: + try: + fd = int(fileobj.fileno()) + except (AttributeError, TypeError, ValueError, OSError): + return -1 + if fd < 0: + raise ValueError("Invalid file descriptor: %d" % fd) + return fd + + +def _check_events(events): + if not events or events & ~_EVENTS_ALL: + raise ValueError("Invalid events: %r" % (events,)) + + +def _poll_mask(events): + mask = 0 + if events & EVENT_READ: + mask |= _POLLIN + if events & EVENT_WRITE: + mask |= _POLLOUT + return mask + + +class BaseSelector: + """Selector abstract base class""" + + def register(self, fileobj, events, data=None): + raise NotImplementedError + + def unregister(self, fileobj): + raise NotImplementedError + + def modify(self, fileobj, events, data=None): + self.unregister(fileobj) + return self.register(fileobj, events, data) + + def select(self, timeout=None): + raise NotImplementedError + + def close(self): + pass + + def get_map(self): + raise NotImplementedError + + def get_key(self, fileobj): + """Return the key registered for fileobj. + + Raises KeyError if not registered, RuntimeError if closed. + """ + mapping = self.get_map() + if mapping is None: + raise RuntimeError("Selector is closed") + if fileobj not in mapping: + raise KeyError("%r is not registered" % (fileobj,)) + return mapping[fileobj] + + def __enter__(self): + return self + + def __exit__(self, *args): + self.close() + + +class PollSelector(BaseSelector): + """Selector based on MicroPython select.poll()""" + + def __init__(self): + self._poll = _select.poll() + self._map = {} + + def _lookup(self, fileobj): + if self._map is None or fileobj not in self._map: + raise KeyError("%r is not registered" % (fileobj,)) + return self._map[fileobj] + + def register(self, fileobj, events, data=None): + if self._map is None: + raise ValueError("Selector is closed") + _check_events(events) + if fileobj in self._map: + raise KeyError("%r is already registered" % (fileobj,)) + key = SelectorKey(fileobj, _fileobj_to_fd(fileobj), events, data) + try: + self._poll.register(fileobj, _poll_mask(events)) + except (TypeError, OSError): # not a stream object + # no chaining: MicroPython warns on 'raise ... from' + raise ValueError("Invalid file object: %r" % (fileobj,)) + self._map[fileobj] = key + return key + + def unregister(self, fileobj): + key = self._lookup(fileobj) + del self._map[fileobj] + try: + self._poll.unregister(fileobj) + except OSError: + pass # object may already be closed + return key + + def modify(self, fileobj, events, data=None): + key = self._lookup(fileobj) + _check_events(events) + if events != key.events: + self._poll.modify(fileobj, _poll_mask(events)) + elif data is key.data: + return key + key = SelectorKey(fileobj, key.fd, events, data) + self._map[fileobj] = key + return key + + def select(self, timeout=None): + """Wait for registered objects to become ready or timeout expire. + + timeout: None blocks, <= 0 polls, else seconds (float allowed). + Returns a list of (key, events) tuples. + """ + if self._map is None: + raise ValueError("Selector is closed") + if timeout is None: + timeout_ms = -1 + elif timeout <= 0: + timeout_ms = 0 + else: + # round up, so a short timeout does not turn into a busy poll + timeout_ms = int(timeout * 1000) + if timeout_ms < timeout * 1000: + timeout_ms += 1 + ready = [] + for fileobj, revents in self._poll.ipoll(timeout_ms): + key = self._map.get(fileobj) + if key is None: + continue + # error/hangup flags wake both directions, like CPython + events = 0 + if revents & ~_POLLIN: + events |= EVENT_WRITE + if revents & ~_POLLOUT: + events |= EVENT_READ + ready.append((key, events & key.events)) + return ready + + def close(self): + self._map = None + self._poll = None + + def get_map(self): + return self._map + + +DefaultSelector = PollSelector diff --git a/python-stdlib/selectors/test_selectors.py b/python-stdlib/selectors/test_selectors.py new file mode 100644 index 000000000..428e71d27 --- /dev/null +++ b/python-stdlib/selectors/test_selectors.py @@ -0,0 +1,205 @@ +# Behaviour tests for selectors. +# +# The tests only use the public selectors API and also pass against CPython's +# stdlib selectors module, so a pass on both means this module behaves like +# CPython's. + +import selectors +import socket +import time +import unittest + +_next_port = [20000 + int(time.time() * 1000) % 20000] + + +def _listener(): + # Return (listening socket, its address). MicroPython unix sockets have + # no getsockname(), so ports are picked here instead of binding to port 0. + while True: + port = _next_port[0] + _next_port[0] += 1 + addr = socket.getaddrinfo("127.0.0.1", port)[0][-1] + listener = socket.socket() + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + listener.bind(addr) + except OSError: + listener.close() + continue + listener.listen(1) + return listener, addr + + +def _ready(sel, timeout=0.5): + # CPython's kqueue selector reports READ and WRITE as separate entries + ready = {} + for key, mask in sel.select(timeout): + ready[key.data] = ready.get(key.data, 0) | mask + return ready + + +class TestSelectors(unittest.TestCase): + def setUp(self): + # connected (listener, client, server-side) TCP socket triple + self.listener, addr = _listener() + self.client = socket.socket() + self.client.connect(addr) + self.conn, _ = self.listener.accept() + self.conn.setblocking(False) + self.client.setblocking(False) + self.sel = selectors.DefaultSelector() + + def tearDown(self): + self.sel.close() + for sock in (self.listener, self.client, self.conn): + sock.close() + + def test_constants(self): + self.assertEqual(selectors.EVENT_READ, 1) + self.assertEqual(selectors.EVENT_WRITE, 2) + + def test_register_returns_key(self): + key = self.sel.register(self.conn, selectors.EVENT_READ, "data") + self.assertIs(key.fileobj, self.conn) + self.assertEqual(key.events, selectors.EVENT_READ) + self.assertEqual(key.data, "data") + self.assertEqual(self.sel.get_key(self.conn), key) + self.assertIn(self.conn, self.sel.get_map()) + + def test_register_errors(self): + sel = self.sel + with self.assertRaises(ValueError): + sel.register(self.conn, 0) + with self.assertRaises(ValueError): + sel.register(self.conn, 4) + with self.assertRaises(ValueError): + sel.register(object(), selectors.EVENT_READ) + with self.assertRaises(ValueError): + sel.register(-1, selectors.EVENT_READ) + self.assertEqual(len(sel.get_map()), 0) + sel.register(self.conn, selectors.EVENT_READ) + with self.assertRaises(KeyError): + sel.register(self.conn, selectors.EVENT_READ) + + def test_select_after_invalid_register(self): + # A rejected object must not break the selector, neither for objects + # registered before it nor for ones registered after it + self.sel.register(self.client, selectors.EVENT_WRITE, "client") + with self.assertRaises(ValueError): + self.sel.register(object(), selectors.EVENT_READ) + self.sel.register(self.conn, selectors.EVENT_READ, "conn") + self.client.send(b"x") + time.sleep(0.05) + self.assertEqual( + _ready(self.sel), + {"conn": selectors.EVENT_READ, "client": selectors.EVENT_WRITE}) + self.sel.unregister(self.client) + self.assertEqual(_ready(self.sel), {"conn": selectors.EVENT_READ}) + + def test_unregister(self): + key = self.sel.register(self.conn, selectors.EVENT_READ, "x") + self.assertEqual(self.sel.unregister(self.conn), key) + with self.assertRaises(KeyError): + self.sel.unregister(self.conn) + with self.assertRaises(KeyError): + self.sel.get_key(self.conn) + self.client.send(b"hello") + time.sleep(0.05) + self.assertEqual(self.sel.select(0), []) + + def test_unregister_after_close(self): + # Unregistering an already closed socket must work + self.sel.register(self.conn, selectors.EVENT_READ) + self.conn.close() + self.sel.unregister(self.conn) + self.assertEqual(len(self.sel.get_map()), 0) + + def test_select_timeout(self): + self.sel.register(self.conn, selectors.EVENT_READ) + start = time.time() + self.assertEqual(self.sel.select(0.1), []) + elapsed = time.time() - start + self.assertTrue(0.05 <= elapsed < 1, elapsed) + self.assertEqual(self.sel.select(0), []) + self.assertEqual(self.sel.select(-1), []) + + def test_select_read_write(self): + self.sel.register(self.conn, selectors.EVENT_READ, "conn") + self.sel.register(self.client, selectors.EVENT_WRITE, "client") + self.assertEqual(_ready(self.sel), {"client": selectors.EVENT_WRITE}) + self.client.send(b"ping") + time.sleep(0.05) + self.assertEqual( + _ready(self.sel), + {"conn": selectors.EVENT_READ, "client": selectors.EVENT_WRITE}) + self.assertEqual(self.conn.recv(16), b"ping") + self.assertEqual(_ready(self.sel), {"client": selectors.EVENT_WRITE}) + + def test_select_masks_by_key_events(self): + # Only the registered events are reported + events = selectors.EVENT_READ | selectors.EVENT_WRITE + self.sel.register(self.conn, events, "conn") + self.assertEqual(_ready(self.sel), {"conn": selectors.EVENT_WRITE}) + self.client.send(b"x") + time.sleep(0.05) + self.assertEqual(_ready(self.sel), {"conn": events}) + + def test_select_accept(self): + listener, addr = _listener() + self.sel.register(listener, selectors.EVENT_READ, "listener") + self.assertEqual(self.sel.select(0), []) + client = socket.socket() + client.connect(addr) + self.assertEqual(_ready(self.sel), {"listener": selectors.EVENT_READ}) + conn, _ = listener.accept() + for sock in (listener, client, conn): + sock.close() + + def test_peer_close_reports_read(self): + self.sel.register(self.conn, selectors.EVENT_READ, "conn") + self.client.close() + time.sleep(0.05) + self.assertEqual(_ready(self.sel), {"conn": selectors.EVENT_READ}) + self.assertEqual(self.conn.recv(16), b"") + + def test_modify(self): + sel = self.sel + key = sel.register(self.conn, selectors.EVENT_READ, "a") + self.assertEqual(sel.select(0), []) + self.assertEqual(sel.modify(self.conn, selectors.EVENT_READ, "a"), key) + key2 = sel.modify(self.conn, selectors.EVENT_READ, "b") + self.assertEqual(key2.data, "b") + self.assertEqual(sel.get_key(self.conn), key2) + key3 = sel.modify(self.conn, selectors.EVENT_WRITE, "c") + self.assertEqual(key3.events, selectors.EVENT_WRITE) + self.assertEqual(_ready(sel), {"c": selectors.EVENT_WRITE}) + with self.assertRaises(ValueError): + sel.modify(self.conn, 0) + with self.assertRaises(KeyError): + sel.modify(self.client, selectors.EVENT_READ) + + def test_close(self): + sel = self.sel + sel.register(self.conn, selectors.EVENT_READ) + sel.close() + self.assertIsNone(sel.get_map()) + with self.assertRaises(RuntimeError): + sel.get_key(self.conn) + with self.assertRaises(KeyError): + sel.unregister(self.conn) + with self.assertRaises(KeyError): + sel.modify(self.conn, selectors.EVENT_READ) + with self.assertRaises(ValueError): + sel.register(self.client, selectors.EVENT_READ) + with self.assertRaises(ValueError): + sel.select(0) + sel.close() # idempotent + + def test_context_manager(self): + with selectors.DefaultSelector() as sel: + sel.register(self.conn, selectors.EVENT_READ) + self.assertIsNone(sel.get_map()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/ci.sh b/tools/ci.sh index 7ee7eb4d1..8c632e775 100755 --- a/tools/ci.sh +++ b/tools/ci.sh @@ -103,6 +103,7 @@ function ci_package_tests_run { python-stdlib/inspect \ python-stdlib/pathlib \ python-stdlib/quopri \ + python-stdlib/selectors \ python-stdlib/shutil \ python-stdlib/tarfile \ python-stdlib/tempfile \