Skip to content

Commit 2434008

Browse files
committed
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 <pavelrevak@gmail.com>
1 parent 660ddd8 commit 2434008

5 files changed

Lines changed: 482 additions & 0 deletions

File tree

‎python-stdlib/selectors/README.md‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# selectors
2+
3+
This library implements a subset of CPython's
4+
[`selectors`](https://docs.python.org/3/library/selectors.html) module as a
5+
thin layer over MicroPython's built-in
6+
[`select.poll()`](https://docs.micropython.org/en/latest/library/select.html).
7+
It lets code written for CPython's high-level I/O multiplexing API run
8+
unchanged on MicroPython, on both the unix port and bare-metal ports.
9+
10+
## Example
11+
12+
```python
13+
import selectors
14+
import socket
15+
16+
sel = selectors.DefaultSelector()
17+
18+
server = socket.socket()
19+
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
20+
server.bind(socket.getaddrinfo("0.0.0.0", 8080)[0][-1])
21+
server.listen(5)
22+
sel.register(server, selectors.EVENT_READ, "accept")
23+
24+
while True:
25+
for key, events in sel.select(timeout=1):
26+
if key.data == "accept":
27+
conn, addr = server.accept()
28+
conn.setblocking(False)
29+
sel.register(conn, selectors.EVENT_READ, "client")
30+
else:
31+
data = key.fileobj.recv(512)
32+
if data:
33+
key.fileobj.send(data)
34+
else:
35+
sel.unregister(key.fileobj)
36+
key.fileobj.close()
37+
```
38+
39+
## Supported API
40+
41+
- `EVENT_READ`, `EVENT_WRITE`
42+
- `SelectorKey(fileobj, fd, events, data)` named tuple
43+
- `BaseSelector` with `register()`, `unregister()`, `modify()`, `select()`,
44+
`close()`, `get_key()`, `get_map()` and context manager support
45+
- `PollSelector`, and `DefaultSelector` as an alias for it
46+
47+
Error handling follows CPython: invalid event masks or file objects raise
48+
`ValueError`, registering an object twice or using an unregistered object
49+
raises `KeyError`, and `get_key()` on a closed selector raises `RuntimeError`.
50+
51+
`select(timeout)` accepts `None` (block), a value `<= 0` (poll without
52+
waiting) or a timeout in seconds (float allowed), and returns a list of
53+
`(key, events)` tuples. Error and hang-up conditions are reported as both
54+
read and write readiness, masked by the events the object was registered for,
55+
the same way as CPython's `PollSelector` does.
56+
57+
## Differences from CPython
58+
59+
- File objects are tracked by identity, not by file descriptor, because
60+
sockets on bare-metal ports have no `fileno()`. Any object supported by
61+
`select.poll()` can be registered, e.g. sockets, SSL sockets, UARTs or
62+
`sys.stdin`. On the unix port an integer file descriptor can be registered
63+
as well.
64+
- `SelectorKey.fd` is the result of `fileno()` when the object provides it,
65+
otherwise `-1`.
66+
- `get_map()` returns a plain `dict` keyed by the registered file object, so
67+
it cannot be indexed by file descriptor. Treat it as read-only.
68+
- Two different objects sharing the same file descriptor are not detected as
69+
a duplicate registration.
70+
- Only `PollSelector` is provided; `SelectSelector`, `EpollSelector`,
71+
`DevpollSelector` and `KqueueSelector` are not available.
72+
73+
## Installation
74+
75+
Use `mip` via `mpremote`:
76+
77+
```bash
78+
> mpremote mip install selectors
79+
```
80+
81+
See [Package
82+
management](https://docs.micropython.org/en/latest/reference/packages.html) for
83+
more details on using `mip` and `mpremote`.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
metadata(version="0.1.0", description="CPython compatible selectors module based on select.poll.")
2+
3+
module("selectors.py")
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
"""selectors - CPython compatible selectors module for MicroPython
2+
3+
Thin layer over MicroPython's select.poll(), usable like the CPython module:
4+
5+
import selectors
6+
sel = selectors.DefaultSelector()
7+
sel.register(sock, selectors.EVENT_READ, data)
8+
for key, mask in sel.select(timeout):
9+
...
10+
11+
Differences from CPython:
12+
- objects are keyed by identity, not by file descriptor (bare-metal
13+
sockets have no fileno()); SelectorKey.fd is fileno() when available,
14+
else -1
15+
- get_map() returns a plain dict keyed by file object (treat it as
16+
read-only)
17+
- only PollSelector is provided, DefaultSelector is an alias for it
18+
19+
MIT license; Copyright (c) 2026 Pavel Revak <pavelrevak@gmail.com>
20+
"""
21+
22+
import select as _select
23+
from collections import namedtuple as _namedtuple
24+
25+
EVENT_READ = 1 << 0
26+
EVENT_WRITE = 1 << 1
27+
28+
_EVENTS_ALL = EVENT_READ | EVENT_WRITE
29+
_POLLIN = _select.POLLIN
30+
_POLLOUT = _select.POLLOUT
31+
32+
SelectorKey = _namedtuple("SelectorKey", ["fileobj", "fd", "events", "data"])
33+
34+
35+
def _fileobj_to_fd(fileobj):
36+
"""Return the file descriptor of fileobj, -1 if it has none"""
37+
if isinstance(fileobj, int):
38+
fd = fileobj
39+
else:
40+
try:
41+
fd = int(fileobj.fileno())
42+
except (AttributeError, TypeError, ValueError, OSError):
43+
return -1
44+
if fd < 0:
45+
raise ValueError("Invalid file descriptor: %d" % fd)
46+
return fd
47+
48+
49+
def _check_events(events):
50+
if not events or events & ~_EVENTS_ALL:
51+
raise ValueError("Invalid events: %r" % (events,))
52+
53+
54+
def _poll_mask(events):
55+
mask = 0
56+
if events & EVENT_READ:
57+
mask |= _POLLIN
58+
if events & EVENT_WRITE:
59+
mask |= _POLLOUT
60+
return mask
61+
62+
63+
class BaseSelector:
64+
"""Selector abstract base class"""
65+
66+
def register(self, fileobj, events, data=None):
67+
raise NotImplementedError
68+
69+
def unregister(self, fileobj):
70+
raise NotImplementedError
71+
72+
def modify(self, fileobj, events, data=None):
73+
self.unregister(fileobj)
74+
return self.register(fileobj, events, data)
75+
76+
def select(self, timeout=None):
77+
raise NotImplementedError
78+
79+
def close(self):
80+
pass
81+
82+
def get_map(self):
83+
raise NotImplementedError
84+
85+
def get_key(self, fileobj):
86+
"""Return the key registered for fileobj.
87+
88+
Raises KeyError if not registered, RuntimeError if closed.
89+
"""
90+
mapping = self.get_map()
91+
if mapping is None:
92+
raise RuntimeError("Selector is closed")
93+
if fileobj not in mapping:
94+
raise KeyError("%r is not registered" % (fileobj,))
95+
return mapping[fileobj]
96+
97+
def __enter__(self):
98+
return self
99+
100+
def __exit__(self, *args):
101+
self.close()
102+
103+
104+
class PollSelector(BaseSelector):
105+
"""Selector based on MicroPython select.poll()"""
106+
107+
def __init__(self):
108+
self._poll = _select.poll()
109+
self._map = {}
110+
111+
def _lookup(self, fileobj):
112+
if self._map is None or fileobj not in self._map:
113+
raise KeyError("%r is not registered" % (fileobj,))
114+
return self._map[fileobj]
115+
116+
def register(self, fileobj, events, data=None):
117+
if self._map is None:
118+
raise ValueError("Selector is closed")
119+
_check_events(events)
120+
if fileobj in self._map:
121+
raise KeyError("%r is already registered" % (fileobj,))
122+
key = SelectorKey(fileobj, _fileobj_to_fd(fileobj), events, data)
123+
try:
124+
self._poll.register(fileobj, _poll_mask(events))
125+
except (TypeError, OSError): # not a stream object
126+
# no chaining: MicroPython warns on 'raise ... from'
127+
raise ValueError("Invalid file object: %r" % (fileobj,))
128+
self._map[fileobj] = key
129+
return key
130+
131+
def unregister(self, fileobj):
132+
key = self._lookup(fileobj)
133+
del self._map[fileobj]
134+
try:
135+
self._poll.unregister(fileobj)
136+
except OSError:
137+
pass # object may already be closed
138+
return key
139+
140+
def modify(self, fileobj, events, data=None):
141+
key = self._lookup(fileobj)
142+
_check_events(events)
143+
if events != key.events:
144+
self._poll.modify(fileobj, _poll_mask(events))
145+
elif data is key.data:
146+
return key
147+
key = SelectorKey(fileobj, key.fd, events, data)
148+
self._map[fileobj] = key
149+
return key
150+
151+
def select(self, timeout=None):
152+
"""Wait for registered objects to become ready or timeout expire.
153+
154+
timeout: None blocks, <= 0 polls, else seconds (float allowed).
155+
Returns a list of (key, events) tuples.
156+
"""
157+
if self._map is None:
158+
raise ValueError("Selector is closed")
159+
if timeout is None:
160+
timeout_ms = -1
161+
elif timeout <= 0:
162+
timeout_ms = 0
163+
else:
164+
# round up, so a short timeout does not turn into a busy poll
165+
timeout_ms = int(timeout * 1000)
166+
if timeout_ms < timeout * 1000:
167+
timeout_ms += 1
168+
ready = []
169+
for fileobj, revents in self._poll.ipoll(timeout_ms):
170+
key = self._map.get(fileobj)
171+
if key is None:
172+
continue
173+
# error/hangup flags wake both directions, like CPython
174+
events = 0
175+
if revents & ~_POLLIN:
176+
events |= EVENT_WRITE
177+
if revents & ~_POLLOUT:
178+
events |= EVENT_READ
179+
ready.append((key, events & key.events))
180+
return ready
181+
182+
def close(self):
183+
self._map = None
184+
self._poll = None
185+
186+
def get_map(self):
187+
return self._map
188+
189+
190+
DefaultSelector = PollSelector

0 commit comments

Comments
 (0)