python-libei
Python bindings for libei, libeis and liboeffis —
the Wayland input-emulation libraries. Use this to move the pointer, click,
type, or scroll on a Wayland desktop from Python, the way xdotool did on
X11.
Pure ctypes, no build step, no dependencies.
device.start_emulating().pointer_motion(5, 0).frame().stop_emulating()
What it's for
Driving a Wayland desktop from Python, when you need real input events rather than a widget-tree back door:
- GUI test automation — click and type at an application the way a user does, against the real compositor.
- Remote desktop and screen sharing — inject the remote user's input into the local session.
- Accessibility tooling — on-screen keyboards, dwell clicking, alternative pointing devices.
- Macros and scripting — the
xdotool-shaped jobs that stopped working when the desktop moved off X11. - Compositor and protocol work —
libei.eisis the server half of the protocol, so an EIS server (or a test double for one) can be written in Python too.
What it isn't
- Not an X11 tool. This speaks the EI/EIS protocol to a compositor that
implements it. On an X server there is nothing to talk to, and nothing here
falls back to XTEST — despite the
xdotoolcomparison above, it is not a drop-in replacement for one. - Keyboard input is key positions, not characters.
keyboard_key()takes Linux evdev keycodes, and what character one produces is the compositor's layout to decide.text_utf8()does send characters directly, but only against libei 1.6 with a TEXT-capable device — see Keys are positions, not characters. - Not a screen-reading library. libei is input only. Pair it with the ScreenCast portal and PipeWire if you also need pixels.
- Not a way around user consent. A real session goes through the portal's
consent dialog, by design. Input that must bypass that prompt belongs at the
kernel layer (
/dev/uinput) instead — a different tool and a different trust model.
What's implemented
Injection covers the input types most automation needs; the rest of libei's capability enum is recognized but not driveable.
DeviceCapability |
What you get |
|---|---|
POINTER |
pointer_motion() |
POINTER_ABSOLUTE |
pointer_motion_absolute(), device.regions |
BUTTON |
button() |
KEYBOARD |
keyboard_key(), device.keymap, KEYBOARD_MODIFIERS events |
SCROLL |
scroll_delta(), scroll_discrete(), scroll_stop(), scroll_cancel() |
TOUCH |
device.touch_new() → down() / motion() / up() |
TEXT |
text_utf8(), text_keysym(), TEXT_UTF8 / TEXT_KEYSYM events (libei 1.6+) |
GESTURES |
not in any released libei — see below |
STYLUS |
not in any released libei — see below |
GESTURES and STYLUS are a different case from the rest of that table.
They, and the swipe/pinch/hold/stylus members of EventType, exist on
libei's main branch but in no released version — 1.6.0's own
enum ei_device_capability stops at TEXT, and its enum ei_event_type
stops at EI_EVENT_TEXT_UTF8. The values here match upstream main
exactly, so they are ready for whatever release adds them; until then,
binding those capabilities against a real library does nothing and the
events cannot arrive. The 22 gesture/stylus accessor functions main adds
are deliberately not bound — nothing that ships today exports them, so
nothing here could be verified against a real library, which is the bar
every other binding in this package was held to.
EventType otherwise mirrors libei's enum in full, and any event type can
be identified and released safely whether or not it has an accessor.
Beyond sending input, the wrapper also covers ping/pong round trips
(Context.new_ping()), touch cancellation (Touch.cancel()), keymap
transfer (Device.keymap), region mapping ids and coordinate conversion,
Context.disconnect(), Context.peek_event_type(), and
Seat.request_device(). On the server side, libei.eis mirrors all of it
and adds Eis.set_flag() and Client.pid.
Underneath, the ctypes layer binds 250 of the 302 functions the three
libraries export as of 1.6.0 (libei 109/132, libeis 131/158, liboeffis
10/12). What is left out is deliberate: *_get_user_data() /
*_set_user_data() (the Python wrapper object is where you keep state),
the *_ref() / *_unref() pairs that CObject handles for you, the
logging-context accessors, *_event_type_to_string(), the NUL-terminated
*_device_text_utf8() (the _with_length form is bound instead, so text
containing a NUL isn't truncated), ei_new() (superseded by
ei_new_sender() / ei_new_receiver()), *_clock_set_now_func(), and the
*_get_context() accessors, which have nothing to hand back: a context is
only ever created by its own create_for_*(), never wrapped from a raw
pointer.
Status
Alpha (0.1.0), not yet on PyPI, and the API is not frozen — expect
renames before 1.0. What that qualifier covers, concretely:
- The injection path — connect, bind, wait for a device, send events — is
exercised end-to-end against the real libraries by
tests/test_integration_socketpair.py, and is in use as the Wayland input backend of a separate GUI-automation project. - Text input, touch cancellation, ping/pong, keymap transfer, region mapping
ids and
peek_event_type()are each round-tripped through a real libeis server intests/test_integration_extras.py. - The portal path (
libei.oeffis) has only ever been verified by hand, since it needs an interactive consent dialog that nothing here can drive. See Troubleshooting. - Verified against libei 1.6.0 on Fedora 44 / GNOME 50.4, and against a locally built 1.2.1 (130 passed, 4 skipped — the 1.4 and 1.6 features gate themselves out). CI repeats the 1.2.1 run on Python 3.10-3.13, so the 1.0.0 core floor is exercised on a real old build rather than asserted. 1.0-1.1 and 1.3 have still never been run against.
Alternatives
| Instead of this | Why you might |
|---|---|
| snegg | The reference bindings, by libei's own author — closer to upstream, and first to get new API. Self-described as for "rapid prototyping" with an explicitly unstable API, and import snegg.ei fails outright where libei isn't installed. docs/vs-snegg.md covers the differences in detail. |
The portal's D-Bus API directly (org.freedesktop.portal.RemoteDesktop, via Gio or dbus-python) |
No native library and no bindings at all — NotifyPointerMotion, NotifyKeyboardKeycode and friends are plain method calls. The catch is absolute motion: NotifyPointerMotionAbsolute needs a PipeWire stream id, which only exists after a second, separate ScreenCast consent dialog. libei has no such requirement. |
ydotool and other /dev/uinput tools |
Kernel-level, so they work under any compositor and need no portal session — at the cost of a privileged daemon, and of sidestepping the consent model that EI exists to enforce. |
Requirements
-
Linux with a Wayland compositor (GNOME, KDE, Sway, …)
-
CPython 3.10 or newer (tested on 3.13)
-
The native libraries: on Fedora,
sudo dnf install libei libeis liboeffis -
libei 1.0.0 or newer for the core: connecting, binding a seat, and sending pointer, button, keyboard, scroll and touch input all use symbols that have existed with a stable signature since 1.0.0, and upstream keeps API/ABI back-compatible within the 1.x series. Only 1.5.0 and 1.6.0 (Fedora 44) have actually been run against.
Newer libei buys you more, per feature:
Needs For 1.1 Region.mapping_id,Region.convert_point(),Device.region_at()1.4 ping/pong round trips ( Context.new_ping()),Context.disconnect(), touch cancellation (Touch.cancel())1.5 eis.Client.pid1.6 Device.text_utf8()/text_keysym()and TEXT events,Seat.request_device(),Eis.set_flag()Nothing is resolved until it is called, so a build without one of these costs you only that call — it raises
LibraryNotFoundErrornaming the missing symbol, while the rest of the package keeps working. The one exception isEvent.touch_up_event, which degrades instead of raising: on libei older than 1.4 it reportsis_cancel=False, since a library with no notion of cancellation genuinely never sends one.These versions were established by building libei 1.2.1 and diffing its exported symbols against every binding here, not by reading
@sinceannotations — three of them are missing upstream, and taking their absence to mean 1.0 got touch cancellation wrong by four releases.
Install
Not published to PyPI yet — install from a checkout:
git clone https://github.com/ctrondlp/python-libei.git
cd python-libei
pip install .
The distribution is named python-libei, the import is libei -- so
pip show python-libei, but from libei import ei.
Importing is always safe, even where the native libraries are missing — they are loaded on first use, not at import. Check before you rely on them:
from libei import ei
if not ei.is_available():
... # fall back to another input backend
Concepts
Five terms are enough to read the rest of this file:
| Term | Meaning |
|---|---|
| Sender | A client that injects input. This is what you want for automation. |
| Receiver | A client that consumes input. For compositor-side code. |
| Seat | A group of input devices, offered by the compositor. You ask it for the capabilities you need. |
| Capability | What kind of input you want: POINTER, KEYBOARD, TOUCH, SCROLL, BUTTON, … |
| Device | What you actually send events through, handed to you after you bind a capability. |
The flow is always the same: connect → bind a capability on a seat → wait for a device → send events through it.
Quick start
Getting an EI connection means asking the desktop portal, which shows the user a consent dialog. After that you have a fd, and everything else is the same regardless of how you got it.
The dialog comes back every run. The portal can be asked to remember an
approval — SelectDevices takes a persist_mode, and returns a
restore_token to hand back next time — but liboeffis does not expose either:
oeffis_create_session() takes a device-type bitmask and nothing else. If
being prompted once per launch is unacceptable for what you're building, see
Avoiding the consent dialog on every run.
import select
from libei import ei, oeffis
# 1. Ask the portal for permission. The user sees a consent dialog.
session = oeffis.Oeffis.create(devices=oeffis.DeviceType.POINTER)
while True:
ready, _, _ = select.select([session.fd], [], [], 30)
if not ready:
raise TimeoutError("portal request timed out")
if session.dispatch():
break # session.eis_fd is now valid
# 2. Connect as a sender.
sender = ei.Sender.create_for_fd(session.eis_fd, name="my-app")
# 3. Ask for a pointer, and wait for the compositor to hand one over.
device = None
while device is None:
select.select([sender.fd], [], [], 5)
sender.dispatch() # events stays empty until dispatch() reads the socket
for event in sender.events:
if event.event_type is ei.EventType.SEAT_ADDED:
event.seat.bind((ei.DeviceCapability.POINTER,))
elif event.event_type is ei.EventType.DEVICE_RESUMED:
device = event.device
# 4. Send input.
device.start_emulating().pointer_motion(5, 0).frame().stop_emulating()
Wait for DEVICE_RESUMED, not DEVICE_ADDED — a device arrives paused, and
libei calls sending events before it resumes "a client bug".
This loop takes the first device to resume, which is fine here because only
POINTER was bound. Bind more than one capability and a seat may resume
several devices — see the absolute-positioning notes under
Sending input before reusing this pattern.
Avoiding the consent dialog on every run
libei.oeffis cannot do it. liboeffis wraps the portal handshake into one
call and exposes no options dict, so the two things that make an approval
persist — persist_mode on SelectDevices, and the restore_token that
comes back on Start — are unreachable through it. This is a limitation of
the C library, not of these bindings; there is nothing here left to bind.
What works is negotiating the portal yourself over D-Bus and handing the
resulting fd to Sender.create_for_fd(), which does not care where the fd
came from:
CreateSessiononorg.freedesktop.portal.RemoteDesktopSelectDeviceswithpersist_mode(1 = while running, 2 = until revoked) and, on later runs, the savedrestore_tokenStart— the response carries a freshrestore_token, which you storeConnectToEIS— the fd forei.Sender.create_for_fd()
Save the token somewhere durable and pass it back next time; the portal then restores the session without prompting. Treat it as a credential — anyone holding it can reopen input injection on that desktop, so it belongs wherever you'd keep a password, and the decision to store it at all belongs to the application rather than to a library.
Sending input
Every burst of input is wrapped in start_emulating() … frame() …
stop_emulating(). frame() is what actually commits the queued events as one
logical hardware event; without it nothing is delivered. Each method returns
the device, so they chain.
# Move the pointer 10px right, 5px down
device.start_emulating().pointer_motion(10, 5).frame().stop_emulating()
# Left click (BTN_LEFT; codes are Linux input codes, from
# <linux/input-event-codes.h>)
BTN_LEFT = 0x110
device.start_emulating()
device.button(BTN_LEFT, True).frame()
device.button(BTN_LEFT, False).frame()
device.stop_emulating()
# Press the A key (KEY_A -- a key *position*, not the character
# "a"; see "Keys are positions, not characters" below)
KEY_A = 30
device.start_emulating()
device.keyboard_key(KEY_A, True).frame()
device.keyboard_key(KEY_A, False).frame()
device.stop_emulating()
# Scroll: smooth (logical pixels) or discrete (one detent is 120)
device.start_emulating().scroll_delta(0, 20).frame().stop_emulating()
device.start_emulating().scroll_discrete(0, 120).frame().stop_emulating()
Keys are positions, not characters
keyboard_key() takes a Linux evdev keycode — a physical key position, not
a character. KEY_A = 30 means "the key where A sits on a US QWERTY board";
under Dvorak or AZERTY the compositor turns that same code into a different
character. There is no type("hello") and no keysym mapping here, so shifted
characters mean sending the modifier yourself:
KEY_LEFTSHIFT, KEY_A = 42, 30
device.start_emulating()
device.keyboard_key(KEY_LEFTSHIFT, True).frame()
device.keyboard_key(KEY_A, True).frame() # "A", not "a"
device.keyboard_key(KEY_A, False).frame()
device.keyboard_key(KEY_LEFTSHIFT, False).frame()
device.stop_emulating()
To get this right for whatever layout the user actually has, read the keymap
the compositor handed you and resolve characters through it — with the
xkbcommon bindings, say, which this package does not depend on:
keymap = device.keymap # None unless the device has KEYBOARD
if keymap is not None:
assert keymap.keymap_type is ei.KeymapType.XKB # the only type so far
with keymap.fd as f: # a fresh dup() each read; closing it is yours
data = f.read(keymap.size)
keymap.fd duplicates libei's descriptor and rewinds the copy for you. Left
to itself a dup() shares the original's file offset, which libei leaves at
the end — reading through it returned zero bytes and no error, which is
indistinguishable from an empty keymap.
Or skip layouts entirely, on libei 1.6
A device with the TEXT capability takes characters directly, and the
compositor works out which keys that means under the active layout:
device.start_emulating().text_utf8("héllo").frame().stop_emulating()
device.start_emulating().text_keysym(0x61, True).frame().stop_emulating()
This is the one path here that types text rather than pressing positions.
It needs libei 1.6 on both sides and a seat that offers
DeviceCapability.TEXT; on anything older, text_utf8() raises
LibraryNotFoundError, so keep the keycode path as a fallback.
Modifier state arrives as events rather than being queryable — watch for
EventType.KEYBOARD_MODIFIERS and read event.keyboard_xkb_modifiers, which
gives depressed, latched, locked and group. That is how you find out
the compositor thinks Caps Lock is on before you start injecting.
Absolute positioning needs the POINTER_ABSOLUTE capability, and coordinates
fall inside one of device.regions:
device.start_emulating().pointer_motion_absolute(960, 540).frame().stop_emulating()
Regions carry more than bounds. region.mapping_id groups the ones that map
to the same thing, device.region_at(x, y) finds which region a point falls
in, and region.convert_point(x, y) turns a desktop-wide point into one
relative to that region — returning None when it falls outside, which also
answers "is it in here?" in a single call. All three need libei 1.1.
Pick the device by capability, not by arrival order. A seat can resume
more than one device — on GNOME you get both a relative virtual pointer
and an absolute shared virtual absolute pointer, and the relative one
arrives first. Reusing the quick-start's "first DEVICE_RESUMED wins" loop
here hands you the relative device, on which pointer_motion_absolute()
does nothing at all: no exception, no movement, just an internal libei
warning (device is not an absolute pointer, visible only if you turn on
logging). Wait for the one you need:
device = None
while device is None:
select.select([sender.fd], [], [], 5)
sender.dispatch()
for event in sender.events:
if event.event_type is ei.EventType.SEAT_ADDED:
event.seat.bind((
ei.DeviceCapability.POINTER_ABSOLUTE,
ei.DeviceCapability.BUTTON,
))
elif event.event_type is ei.EventType.DEVICE_RESUMED:
if ei.DeviceCapability.POINTER_ABSOLUTE in event.device.capabilities:
device = event.device # skip the relative sibling
Touch uses its own short-lived object rather than the device directly:
touch = device.touch_new()
device.start_emulating()
touch.down(100, 200)
device.frame()
touch.motion(150, 250)
device.frame()
touch.up() # or touch.cancel(), if the gesture was aborted
device.frame()
device.stop_emulating()
A cancelled touch still reaches the other side as a TOUCH_UP event; what
separates it from a normal release is event.touch_up_event.is_cancel. Both
sides need version 2 or later of the ei_touchscreen interface, and against
anything older cancel() is a noop.
Things that will bite you
-
dispatch()beforeevents.eventsdrains only what is already queued; it yields nothing untildispatch()has read from the socket. -
Don't keep an event past its loop iteration. Each event is released as soon as the loop moves on, and using it afterwards raises
RuntimeError. Objects you pull off an event (event.device,event.seat) are safe to keep — copy outevent.pointer_eventand friends rather than the event. -
frame()or nothing happens. Events queue up until aframe()commits them. -
bind()needs at least one capability. Binding an empty set sends nothing, so the device you are waiting for never arrives; this raisesValueErrorrather than hanging. -
Capabilities are per-seat. A seat only offers some; check
seat.capabilitiesbefore binding. -
One seat can resume several devices. Bind both
POINTERandPOINTER_ABSOLUTEand GNOME gives you two, relative first. Taking whichever resumes first is a coin flip — select ondevice.capabilitiesinstead. Sending an event the device lacks the capability for is silently ignored, which makes this look like the injection simply not working. -
Read the accessor that matches the event type.
event.key_eventon aPOINTER_MOTIONevent raisesTypeErrornaming both types. libei itself would have returnedKeyEvent(key=0, is_press=False)— a real-looking value — while logging aBug:line the caller never sees, so branch onevent_typefirst.TOUCH_UPhas its owntouch_up_event, since it carries no coordinates. -
GESTURESandSTYLUSare not in any released libei. They match upstreammainand are here ready for it, but 1.6.0's capability enum stops atTEXT. Binding them against a shipping library silently does nothing — no error, no device, no events.
Reading input instead of sending it
Use ei.Receiver in place of ei.Sender — same connection dance, but events
carry input from the compositor:
receiver = ei.Receiver.create_for_fd(eis_fd, name="my-app")
receiver.dispatch()
for event in receiver.events:
if event.event_type is ei.EventType.POINTER_MOTION:
motion = event.pointer_event
print(motion.dx, motion.dy)
Each event type has its own getter — key_event, button_event,
pointer_event, pointer_absolute_event, scroll_event,
scroll_discrete_event, scroll_stop_event, touch_event and
touch_up_event, text_utf8_event, text_keysym_event and
keyboard_xkb_modifiers — and each checks the event's type before reading,
raising TypeError rather than handing back the zero-filled result libei
would give for a mismatch.
Gesture and stylus events have no accessor and cannot arrive from a
released libei at all (see What's implemented), but
they can be identified and skipped safely if they ever do. So can event
types this package has never heard of:
event_type returns a plain int rather than raising, because libei's own
header says the enum "is not exhaustive". To look at what is coming without
consuming it, peek_event_type() reports the next event's type and leaves it
queued.
Connection lifecycle
Beyond sending input, a long-lived client usually wants three things.
Check the connection is alive. A ping is a round trip that comes back as
a PONG event carrying the same object, so several can be in flight at once
and still be told apart:
ping = sender.new_ping()
ping.send()
# ... later, in the event loop:
# if event.event_type is ei.EventType.PONG and event.pong.id == ping.id:
# ...
Ask for another device. If you closed one, or the ones the seat gave you
no longer cover what you need, seat.request_device((cap, ...)) asks for
another — a subset of what bind() requested. The server may answer with
different capabilities, or not at all; anything it does create arrives as a
DEVICE_ADDED event. Needs libei 1.6.
Shut down deliberately. sender.disconnect() tears the session down
through the event queue: seats and devices are removed as though the server
had done it, and DISCONNECT is the last event you get. The context is inert
afterwards but still needs releasing like any other object. Needs libei 1.4.
Running your own EIS server
libei.eis is the compositor side of the protocol. Most people want it for
testing — it lets you drive the client code above without a real compositor
or a consent dialog:
import select
from libei import eis
server = eis.Eis.create_for_fd()
client_fd = server.add_client() # hand this fd to a client's ei.Sender/Receiver
while True:
select.select([server.fd], [], [])
server.dispatch() # events is empty until dispatch() reads the connection
for event in server.events:
if event.event_type is eis.EventType.CLIENT_CONNECT:
event.client.connect()
seat = event.client.new_seat("default")
seat.configure_capabilities((eis.DeviceCapability.POINTER,))
seat.add()
elif event.event_type is eis.EventType.SEAT_BIND:
device = event.seat.new_device()
device.configure(
name="my-pointer", capabilities=(eis.DeviceCapability.POINTER,)
)
device.add()
device.resume() # until you resume it, the client may not send
tests/test_integration_socketpair.py is a complete, working version of both
halves — connect, negotiate, and round-trip a pointer motion, in one process
against the real library.
Logging
libei's own diagnostics are routed into Python's logging — the libei.ei,
libei.eis and libei.oeffis loggers — rather than being written to stderr
by the C library. This is how you see the warnings that otherwise look like
nothing happening at all, device is not an absolute pointer among them:
import logging
logging.basicConfig(level=logging.DEBUG)
logging.getLogger("libei").setLevel(logging.DEBUG)
At DEBUG this is a full protocol trace (every object, message and
signature, both directions — a few hundred lines for a single connect and
one pointer motion), which makes it the first thing to reach for when a
negotiation stalls. WARNING gets you just libei's complaints.
Troubleshooting
The portal dialog appears, I approve it, and nothing happens. Earlier
testing on GNOME 44 saw the round trip hang indefinitely even after clicking
through the dialog, and it went unroot-caused for a while. Revisited
2026-08-25 on GNOME 50.4 with a busctl monitor trace on the real portal:
CreateSession -> SelectDevices -> Start -> ConnectToEIS completed cleanly in
a few seconds, 3/3 consecutive attempts, with Start()'s Response signal
arriving only after a multi-second gap consistent with a real dialog being
answered. The code was correctly waiting the whole time -- dispatch()
returning False just means no Response has arrived yet.
The likely explanation for the earlier hangs: RemoteDesktop.Start() can
involve more than one prompt (an access-request dialog, then a device-sharing
confirmation), and dismissing or missing one leaves Start() never returning
-- indistinguishable from a hang on the caller's side. Not independently
confirmed by watching the dialogs themselves, only inferred from this trace
plus which step it stalled at previously; if you hit this again, check
whether a second prompt is waiting before assuming it's this library.
libei.eis remains the right fallback for anything that doesn't need the
portal at all, e.g. tests.
LibraryNotFoundError. The native library isn't installed, or is too old
to export a function this package binds. Check with ei.is_available().
My events never arrive. Almost always a missing frame(), or emulating
before DEVICE_RESUMED, or a device that lacks the capability for the event
you're sending — all three fail silently. Turn on logging at
DEBUG to see what actually reached the compositor.
The loop hangs waiting for a device. Check that the seat actually offers
the capability you bound (seat.capabilities).
API summary
| Module | Use it for |
|---|---|
libei.ei |
Clients: Sender (inject), Receiver (consume) |
libei.eis |
Servers: Eis, for compositors and for testing clients |
libei.oeffis |
Getting an EI fd from the desktop portal |
Each module has is_available(), an Error exception, and an EventType /
DeviceCapability enum. ei and eis also share the shapes around them:
Device, Seat, Region, Keymap, Touch, Ping, Event, and the frozen
dataclasses its accessors return. The package ships py.typed, so callers
type-check against real annotations rather than Any.
For which of libei's capabilities are actually driveable, and which C functions are left unbound, see What's implemented.
Development
Architecture
Four layers, bottom up. If you're reading the code for the first time, read them in this order -- each one only makes sense once the one below it does.
| Layer | What it does |
|---|---|
_capi/loader.py |
LazyLibrary: dlopens a .so on first call, not at import, so this package imports fine with no native libraries installed |
_capi/libei.py, libeis.py, liboeffis.py |
One line per C function, with hand-written ctypes signatures. Nothing else -- no logic |
_cobject.py |
CObject: pointer ownership, refcounting, and the identity cache that every wrapper class inherits |
ei.py, eis.py, oeffis.py |
The public API: Python classes, enums and dataclasses over the raw calls |
Read _cobject.py first. It is the smallest file with the most
consequence: get wrap() vs adopt(), the staticmethod() wrapping of
_ref_func/_unref_func, or the _wrappable flag wrong and the failure is
a use-after-free or a segfault rather than a traceback. Every non-obvious
line there carries a comment explaining what breaks without it.
Two conventions worth knowing before the code reads cleanly:
_capinames drop the C prefix.ei_unref()is_capi.libei.unref(),eis_device_configure_name()is_capi.libeis.device_configure_name(). The module says which library it is, so repeating it in every name would only add noise.- Wrapper instances are passed straight to C calls.
CObjectdefines_as_parameter_, which ctypes consults automatically, so_capi.libei.device_frame(self, timestamp)works without unwrapping a pointer out ofselfby hand.
Setup and checks
python -m venv .venv && . .venv/bin/activate
pip install -e '.[dev]'
ruff check src tests
python -m mypy
pytest # everything; the integration tests below
# skip themselves if libei/libeis is absent
pytest -m integration # only the tests that drive the real libraries
pytest -rs # ...and report which tests skipped, and why
Tests for a feature the installed libei is too old to provide skip
themselves by checking for the symbol before they negotiate anything --
tests/conftest.py's requires_symbol(). Checking up front rather than
catching the failure matters: a capability an older library has never heard
of is accepted silently and simply yields no device, so a test that waited
for one would hang to its timeout instead of skipping.
CI runs the suite on Python 3.10-3.13 against Ubuntu's libei, which is 1.2.1 -- deliberately older than the 1.6.0 used for development, so the 1.0.0 core floor and the version gates both get exercised on a real build rather than only on paper. To reproduce that locally, build an old libei and point the loader at it:
git clone --depth 1 --branch 1.2.1 \
https://gitlab.freedesktop.org/libinput/libei.git
cd libei && meson setup build -Dtests=disabled -Ddocumentation=[] \
--prefix=$PWD/prefix && ninja -C build install
LD_LIBRARY_PATH=$PWD/prefix/lib64 pytest -q -rs # from this checkout
Expect passes plus skips, never failures or hangs.
A separate job installs the package with no native libraries at all and imports it, which is the property the lazy loader exists to provide.
Releasing
Versions are SemVer and live in two places -- pyproject.toml and
src/libei/__init__.py -- which have to agree with each other and with the
tag. Nothing enforces that yet.
A release is an annotated, v-prefixed tag plus a GitHub Release:
git tag -a v0.1.0 -m "0.1.0"
git push origin v0.1.0
gh release create v0.1.0 --generate-notes --prerelease
--prerelease while the API is unfrozen -- it keeps an alpha out of the
"Latest release" slot.
Publishing runs from CI on a v* tag using PyPI
Trusted Publishing (OIDC), so
there is no API token in repository secrets to leak or rotate. The publish
job in ci.yml handles it, uploading the artifacts the build job already
ran twine check over.
That job depends on two pieces of configuration outside this repository, which have to exist before the first tag:
- On pypi.org, under Account settings -> Publishing, a pending
publisher -- the flow for a project that has no releases yet. Project
python-libei, ownerctrondlp, repositorypython-libei, workflowci.yml, environmentpypi. Every field has to match the workflow exactly; a mismatch surfaces as a rejected credential at upload time, not when it is saved. - A GitHub environment named
pypi, in the repository settings. A required reviewer on it makes each publish a deliberate approval rather than a side effect of pushing a tag.
Worth rehearsing on TestPyPI first: separate account, separate pending
publisher, and repository-url: https://test.pypi.org/legacy/ on the
publish step. PyPI filenames are immutable, so a bad upload can only be
yanked and superseded by a new version, never replaced.
Design notes
Written from scratch, taking its overall shape from
snegg (the reference bindings by
libei's own author), with different priorities suited to being embedded as a
dependency rather than used for prototyping.
docs/vs-snegg.md covers the specifics, including two
signature issues found by cross-checking against the real libei source.
License
MIT.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file python_libei-0.1.0.tar.gz.
File metadata
- Download URL: python_libei-0.1.0.tar.gz
- Upload date:
- Size: 104.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d4c7a2682c065fc8e631cea67940d6fb3b7c33da74500c9092125427e828c6f8
|
|
| MD5 |
82ff7fd18f67146db64ecd177b5e3671
|
|
| BLAKE2b-256 |
85d14d80c77c9a6d0ea96d09e65971662ef3f1abc238a30733d91b29b054fb13
|
Provenance
The following attestation bundles were made for python_libei-0.1.0.tar.gz:
Publisher:
ci.yml on ctrondlp/python-libei
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
python_libei-0.1.0.tar.gz -
Subject digest:
d4c7a2682c065fc8e631cea67940d6fb3b7c33da74500c9092125427e828c6f8 - Sigstore transparency entry: 2629772915
- Sigstore integration time:
-
Permalink:
ctrondlp/python-libei@189774f3f3d1c2f5de734edee570101be773bfd9 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/ctrondlp
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
ci.yml@189774f3f3d1c2f5de734edee570101be773bfd9 -
Trigger Event:
push
-
Statement type:
File details
Details for the file python_libei-0.1.0-py3-none-any.whl.
File metadata
- Download URL: python_libei-0.1.0-py3-none-any.whl
- Upload date:
- Size: 58.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9d043ae7a6e2551b1c7a97c80d3f0271848842292ceabbd285e0e95bbc57f0b5
|
|
| MD5 |
def15ea2987d163bea595bfc122a0db9
|
|
| BLAKE2b-256 |
e35379063f870f5e7f886d71a9f4cb34a7397bf3dd5f69440de116e5ff280577
|
Provenance
The following attestation bundles were made for python_libei-0.1.0-py3-none-any.whl:
Publisher:
ci.yml on ctrondlp/python-libei
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
python_libei-0.1.0-py3-none-any.whl -
Subject digest:
9d043ae7a6e2551b1c7a97c80d3f0271848842292ceabbd285e0e95bbc57f0b5 - Sigstore transparency entry: 2629773568
- Sigstore integration time:
-
Permalink:
ctrondlp/python-libei@189774f3f3d1c2f5de734edee570101be773bfd9 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/ctrondlp
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
ci.yml@189774f3f3d1c2f5de734edee570101be773bfd9 -
Trigger Event:
push
-
Statement type: