Skip to main content

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 worklibei.eis is 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 xdotool comparison 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 in tests/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.pid
    1.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 LibraryNotFoundError naming the missing symbol, while the rest of the package keeps working. The one exception is Event.touch_up_event, which degrades instead of raising: on libei older than 1.4 it reports is_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 @since annotations — 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:

  1. CreateSession on org.freedesktop.portal.RemoteDesktop
  2. SelectDevices with persist_mode (1 = while running, 2 = until revoked) and, on later runs, the saved restore_token
  3. Start — the response carries a fresh restore_token, which you store
  4. ConnectToEIS — the fd for ei.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() before events. events drains only what is already queued; it yields nothing until dispatch() 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 out event.pointer_event and friends rather than the event.

  • frame() or nothing happens. Events queue up until a frame() commits them.

  • bind() needs at least one capability. Binding an empty set sends nothing, so the device you are waiting for never arrives; this raises ValueError rather than hanging.

  • Capabilities are per-seat. A seat only offers some; check seat.capabilities before binding.

  • One seat can resume several devices. Bind both POINTER and POINTER_ABSOLUTE and GNOME gives you two, relative first. Taking whichever resumes first is a coin flip — select on device.capabilities instead. 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_event on a POINTER_MOTION event raises TypeError naming both types. libei itself would have returned KeyEvent(key=0, is_press=False) — a real-looking value — while logging a Bug: line the caller never sees, so branch on event_type first. TOUCH_UP has its own touch_up_event, since it carries no coordinates.

  • GESTURES and STYLUS are not in any released libei. They match upstream main and are here ready for it, but 1.6.0's capability enum stops at TEXT. 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:

  • _capi names 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. CObject defines _as_parameter_, which ctypes consults automatically, so _capi.libei.device_frame(self, timestamp) works without unwrapping a pointer out of self by 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:

  1. On pypi.org, under Account settings -> Publishing, a pending publisher -- the flow for a project that has no releases yet. Project python-libei, owner ctrondlp, repository python-libei, workflow ci.yml, environment pypi. Every field has to match the workflow exactly; a mismatch surfaces as a rejected credential at upload time, not when it is saved.
  2. 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

python_libei-0.1.0.tar.gz (104.7 kB view details)

Uploaded Source

Built Distribution

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

python_libei-0.1.0-py3-none-any.whl (58.7 kB view details)

Uploaded Python 3

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

Hashes for python_libei-0.1.0.tar.gz
Algorithm Hash digest
SHA256 d4c7a2682c065fc8e631cea67940d6fb3b7c33da74500c9092125427e828c6f8
MD5 82ff7fd18f67146db64ecd177b5e3671
BLAKE2b-256 85d14d80c77c9a6d0ea96d09e65971662ef3f1abc238a30733d91b29b054fb13

See more details on using hashes here.

Provenance

The following attestation bundles were made for python_libei-0.1.0.tar.gz:

Publisher: ci.yml on ctrondlp/python-libei

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

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

Hashes for python_libei-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 9d043ae7a6e2551b1c7a97c80d3f0271848842292ceabbd285e0e95bbc57f0b5
MD5 def15ea2987d163bea595bfc122a0db9
BLAKE2b-256 e35379063f870f5e7f886d71a9f4cb34a7397bf3dd5f69440de116e5ff280577

See more details on using hashes here.

Provenance

The following attestation bundles were made for python_libei-0.1.0-py3-none-any.whl:

Publisher: ci.yml on ctrondlp/python-libei

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

Release history Release notifications | RSS feed

0.2.0

2 files

This release

0.1.0 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page