python-libei 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,770 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-libei
3
+ Version: 0.1.0
4
+ Summary: Inject and receive input on Wayland from Python: ctypes bindings for libei, libeis and liboeffis
5
+ Author: Dennis K. Paulsen
6
+ License-Expression: MIT
7
+ Project-URL: homepage, https://github.com/ctrondlp/python-libei
8
+ Project-URL: repository, https://github.com/ctrondlp/python-libei
9
+ Project-URL: issues, https://github.com/ctrondlp/python-libei/issues
10
+ Keywords: wayland,libei,libeis,liboeffis,input,input-emulation,emulated-input,portal,xdg-desktop-portal,remote-desktop,automation,gui-testing,accessibility,ctypes
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: POSIX :: Linux
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Desktop Environment
21
+ Classifier: Topic :: Software Development :: Libraries
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Classifier: Topic :: Software Development :: Testing
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: >=3.10
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest>=7; extra == "dev"
30
+ Requires-Dist: ruff>=0.6; extra == "dev"
31
+ Requires-Dist: mypy>=1.11; extra == "dev"
32
+ Dynamic: license-file
33
+
34
+ # python-libei
35
+
36
+ Python bindings for [libei, libeis and liboeffis](https://libinput.pages.freedesktop.org/libei/) —
37
+ the Wayland input-emulation libraries. Use this to **move the pointer, click,
38
+ type, or scroll on a Wayland desktop** from Python, the way `xdotool` did on
39
+ X11.
40
+
41
+ Pure ctypes, no build step, no dependencies.
42
+
43
+ ```python
44
+ device.start_emulating().pointer_motion(5, 0).frame().stop_emulating()
45
+ ```
46
+
47
+ ## What it's for
48
+
49
+ Driving a Wayland desktop from Python, when you need real input events rather
50
+ than a widget-tree back door:
51
+
52
+ - **GUI test automation** — click and type at an application the way a user
53
+ does, against the real compositor.
54
+ - **Remote desktop and screen sharing** — inject the remote user's input into
55
+ the local session.
56
+ - **Accessibility tooling** — on-screen keyboards, dwell clicking, alternative
57
+ pointing devices.
58
+ - **Macros and scripting** — the `xdotool`-shaped jobs that stopped working
59
+ when the desktop moved off X11.
60
+ - **Compositor and protocol work** — `libei.eis` is the server half of the
61
+ protocol, so an EIS server (or a test double for one) can be written in
62
+ Python too.
63
+
64
+ ### What it isn't
65
+
66
+ - **Not an X11 tool.** This speaks the EI/EIS protocol to a compositor that
67
+ implements it. On an X server there is nothing to talk to, and nothing here
68
+ falls back to XTEST — despite the `xdotool` comparison above, it is not a
69
+ drop-in replacement for one.
70
+ - **Keyboard input is key positions, not characters.** `keyboard_key()`
71
+ takes Linux evdev *keycodes*, and what character one produces is the
72
+ compositor's layout to decide. `text_utf8()` does send characters
73
+ directly, but only against libei 1.6 with a TEXT-capable device — see
74
+ [Keys are positions, not characters](#keys-are-positions-not-characters).
75
+ - **Not a screen-reading library.** libei is input only. Pair it with the
76
+ ScreenCast portal and PipeWire if you also need pixels.
77
+ - **Not a way around user consent.** A real session goes through the portal's
78
+ consent dialog, by design. Input that must bypass that prompt belongs at the
79
+ kernel layer (`/dev/uinput`) instead — a different tool and a different
80
+ trust model.
81
+
82
+ ## What's implemented
83
+
84
+ Injection covers the input types most automation needs; the rest of libei's
85
+ capability enum is recognized but not driveable.
86
+
87
+ | `DeviceCapability` | What you get |
88
+ | --- | --- |
89
+ | `POINTER` | `pointer_motion()` |
90
+ | `POINTER_ABSOLUTE` | `pointer_motion_absolute()`, `device.regions` |
91
+ | `BUTTON` | `button()` |
92
+ | `KEYBOARD` | `keyboard_key()`, `device.keymap`, `KEYBOARD_MODIFIERS` events |
93
+ | `SCROLL` | `scroll_delta()`, `scroll_discrete()`, `scroll_stop()`, `scroll_cancel()` |
94
+ | `TOUCH` | `device.touch_new()` → `down()` / `motion()` / `up()` |
95
+ | `TEXT` | `text_utf8()`, `text_keysym()`, `TEXT_UTF8` / `TEXT_KEYSYM` events (libei 1.6+) |
96
+ | `GESTURES` | not in any released libei — see below |
97
+ | `STYLUS` | not in any released libei — see below |
98
+
99
+ `GESTURES` and `STYLUS` are a different case from the rest of that table.
100
+ They, and the swipe/pinch/hold/stylus members of `EventType`, exist on
101
+ libei's `main` branch but in **no released version** — 1.6.0's own
102
+ `enum ei_device_capability` stops at `TEXT`, and its `enum ei_event_type`
103
+ stops at `EI_EVENT_TEXT_UTF8`. The values here match upstream `main`
104
+ exactly, so they are ready for whatever release adds them; until then,
105
+ binding those capabilities against a real library does nothing and the
106
+ events cannot arrive. The 22 gesture/stylus accessor functions `main` adds
107
+ are deliberately not bound — nothing that ships today exports them, so
108
+ nothing here could be verified against a real library, which is the bar
109
+ every other binding in this package was held to.
110
+
111
+ `EventType` otherwise mirrors libei's enum in full, and any event type can
112
+ be *identified* and released safely whether or not it has an accessor.
113
+
114
+ Beyond sending input, the wrapper also covers ping/pong round trips
115
+ (`Context.new_ping()`), touch cancellation (`Touch.cancel()`), keymap
116
+ transfer (`Device.keymap`), region mapping ids and coordinate conversion,
117
+ `Context.disconnect()`, `Context.peek_event_type()`, and
118
+ `Seat.request_device()`. On the server side, `libei.eis` mirrors all of it
119
+ and adds `Eis.set_flag()` and `Client.pid`.
120
+
121
+ Underneath, the ctypes layer binds 250 of the 302 functions the three
122
+ libraries export as of 1.6.0 (libei 109/132, libeis 131/158, liboeffis
123
+ 10/12). What is left out is deliberate: `*_get_user_data()` /
124
+ `*_set_user_data()` (the Python wrapper object is where you keep state),
125
+ the `*_ref()` / `*_unref()` pairs that `CObject` handles for you, the
126
+ logging-context accessors, `*_event_type_to_string()`, the NUL-terminated
127
+ `*_device_text_utf8()` (the `_with_length` form is bound instead, so text
128
+ containing a NUL isn't truncated), `ei_new()` (superseded by
129
+ `ei_new_sender()` / `ei_new_receiver()`), `*_clock_set_now_func()`, and the
130
+ `*_get_context()` accessors, which have nothing to hand back: a context is
131
+ only ever created by its own `create_for_*()`, never wrapped from a raw
132
+ pointer.
133
+
134
+ ## Status
135
+
136
+ Alpha (`0.1.0`), not yet on PyPI, and the API is not frozen — expect
137
+ renames before 1.0. What that qualifier covers, concretely:
138
+
139
+ - The injection path — connect, bind, wait for a device, send events — is
140
+ exercised end-to-end against the real libraries by
141
+ `tests/test_integration_socketpair.py`, and is in use as the Wayland input
142
+ backend of a separate GUI-automation project.
143
+ - Text input, touch cancellation, ping/pong, keymap transfer, region mapping
144
+ ids and `peek_event_type()` are each round-tripped through a real libeis
145
+ server in `tests/test_integration_extras.py`.
146
+ - The portal path (`libei.oeffis`) has only ever been verified by hand, since
147
+ it needs an interactive consent dialog that nothing here can drive. See
148
+ [Troubleshooting](#troubleshooting).
149
+ - Verified against libei 1.6.0 on Fedora 44 / GNOME 50.4, and against a
150
+ locally built 1.2.1 (130 passed, 4 skipped — the 1.4 and 1.6 features
151
+ gate themselves out). CI repeats the 1.2.1 run on Python 3.10-3.13, so
152
+ the 1.0.0 core floor is exercised on a real old build rather than
153
+ asserted. 1.0-1.1 and 1.3 have still never been run against.
154
+
155
+ ## Alternatives
156
+
157
+ | Instead of this | Why you might |
158
+ | --- | --- |
159
+ | [snegg](https://gitlab.freedesktop.org/whot/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`](docs/vs-snegg.md) covers the differences in detail. |
160
+ | 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. |
161
+ | `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. |
162
+
163
+ ## Requirements
164
+
165
+ - Linux with a Wayland compositor (GNOME, KDE, Sway, …)
166
+ - CPython 3.10 or newer (tested on 3.13)
167
+ - The native libraries: on Fedora, `sudo dnf install libei libeis liboeffis`
168
+ - libei 1.0.0 or newer for the core: connecting, binding a seat, and
169
+ sending pointer, button, keyboard, scroll and touch input all use symbols
170
+ that have existed with a stable signature since 1.0.0, and upstream keeps
171
+ API/ABI back-compatible within the 1.x series. Only 1.5.0 and 1.6.0
172
+ (Fedora 44) have actually been run against.
173
+
174
+ Newer libei buys you more, per feature:
175
+
176
+ | Needs | For |
177
+ | --- | --- |
178
+ | 1.1 | `Region.mapping_id`, `Region.convert_point()`, `Device.region_at()` |
179
+ | 1.4 | ping/pong round trips (`Context.new_ping()`), `Context.disconnect()`, touch cancellation (`Touch.cancel()`) |
180
+ | 1.5 | `eis.Client.pid` |
181
+ | 1.6 | `Device.text_utf8()` / `text_keysym()` and TEXT events, `Seat.request_device()`, `Eis.set_flag()` |
182
+
183
+ Nothing is resolved until it is called, so a build without one of these
184
+ costs you only that call — it raises `LibraryNotFoundError` naming the
185
+ missing symbol, while the rest of the package keeps working. The one
186
+ exception is `Event.touch_up_event`, which degrades instead of raising:
187
+ on libei older than 1.4 it reports `is_cancel=False`, since a library
188
+ with no notion of cancellation genuinely never sends one.
189
+
190
+ These versions were established by building libei 1.2.1 and diffing its
191
+ exported symbols against every binding here, not by reading `@since`
192
+ annotations — three of them are missing upstream, and taking their
193
+ absence to mean 1.0 got touch cancellation wrong by four releases.
194
+
195
+ ## Install
196
+
197
+ Not published to PyPI yet — install from a checkout:
198
+
199
+ ```sh
200
+ git clone https://github.com/ctrondlp/python-libei.git
201
+ cd python-libei
202
+ pip install .
203
+ ```
204
+
205
+ The distribution is named `python-libei`, the import is `libei` -- so
206
+ `pip show python-libei`, but `from libei import ei`.
207
+
208
+ Importing is always safe, even where the native libraries are missing — they
209
+ are loaded on first use, not at import. Check before you rely on them:
210
+
211
+ ```python
212
+ from libei import ei
213
+
214
+ if not ei.is_available():
215
+ ... # fall back to another input backend
216
+ ```
217
+
218
+ ## Concepts
219
+
220
+ Five terms are enough to read the rest of this file:
221
+
222
+ | Term | Meaning |
223
+ | --- | --- |
224
+ | **Sender** | A client that *injects* input. This is what you want for automation. |
225
+ | **Receiver** | A client that *consumes* input. For compositor-side code. |
226
+ | **Seat** | A group of input devices, offered by the compositor. You ask it for the capabilities you need. |
227
+ | **Capability** | What kind of input you want: `POINTER`, `KEYBOARD`, `TOUCH`, `SCROLL`, `BUTTON`, … |
228
+ | **Device** | What you actually send events through, handed to you after you bind a capability. |
229
+
230
+ The flow is always the same: **connect → bind a capability on a seat → wait
231
+ for a device → send events through it.**
232
+
233
+ ## Quick start
234
+
235
+ Getting an EI connection means asking the desktop portal, which shows the user
236
+ a consent dialog. After that you have a fd, and everything else is the same
237
+ regardless of how you got it.
238
+
239
+ **The dialog comes back every run.** The portal can be asked to remember an
240
+ approval — `SelectDevices` takes a `persist_mode`, and returns a
241
+ `restore_token` to hand back next time — but liboeffis does not expose either:
242
+ `oeffis_create_session()` takes a device-type bitmask and nothing else. If
243
+ being prompted once per launch is unacceptable for what you're building, see
244
+ [Avoiding the consent dialog on every run](#avoiding-the-consent-dialog-on-every-run).
245
+
246
+ ```python
247
+ import select
248
+ from libei import ei, oeffis
249
+
250
+ # 1. Ask the portal for permission. The user sees a consent dialog.
251
+ session = oeffis.Oeffis.create(devices=oeffis.DeviceType.POINTER)
252
+ while True:
253
+ ready, _, _ = select.select([session.fd], [], [], 30)
254
+ if not ready:
255
+ raise TimeoutError("portal request timed out")
256
+ if session.dispatch():
257
+ break # session.eis_fd is now valid
258
+
259
+ # 2. Connect as a sender.
260
+ sender = ei.Sender.create_for_fd(session.eis_fd, name="my-app")
261
+
262
+ # 3. Ask for a pointer, and wait for the compositor to hand one over.
263
+ device = None
264
+ while device is None:
265
+ select.select([sender.fd], [], [], 5)
266
+ sender.dispatch() # events stays empty until dispatch() reads the socket
267
+ for event in sender.events:
268
+ if event.event_type is ei.EventType.SEAT_ADDED:
269
+ event.seat.bind((ei.DeviceCapability.POINTER,))
270
+ elif event.event_type is ei.EventType.DEVICE_RESUMED:
271
+ device = event.device
272
+
273
+ # 4. Send input.
274
+ device.start_emulating().pointer_motion(5, 0).frame().stop_emulating()
275
+ ```
276
+
277
+ Wait for `DEVICE_RESUMED`, not `DEVICE_ADDED` — a device arrives paused, and
278
+ libei calls sending events before it resumes "a client bug".
279
+
280
+ This loop takes the first device to resume, which is fine here because only
281
+ `POINTER` was bound. Bind more than one capability and a seat may resume
282
+ several devices — see the absolute-positioning notes under
283
+ [Sending input](#sending-input) before reusing this pattern.
284
+
285
+ ### Avoiding the consent dialog on every run
286
+
287
+ `libei.oeffis` cannot do it. liboeffis wraps the portal handshake into one
288
+ call and exposes no options dict, so the two things that make an approval
289
+ persist — `persist_mode` on `SelectDevices`, and the `restore_token` that
290
+ comes back on `Start` — are unreachable through it. This is a limitation of
291
+ the C library, not of these bindings; there is nothing here left to bind.
292
+
293
+ What works is negotiating the portal yourself over D-Bus and handing the
294
+ resulting fd to `Sender.create_for_fd()`, which does not care where the fd
295
+ came from:
296
+
297
+ 1. `CreateSession` on `org.freedesktop.portal.RemoteDesktop`
298
+ 2. `SelectDevices` with `persist_mode` (1 = while running, 2 = until
299
+ revoked) and, on later runs, the saved `restore_token`
300
+ 3. `Start` — the response carries a fresh `restore_token`, which you store
301
+ 4. `ConnectToEIS` — the fd for `ei.Sender.create_for_fd()`
302
+
303
+ Save the token somewhere durable and pass it back next time; the portal then
304
+ restores the session without prompting. Treat it as a credential — anyone
305
+ holding it can reopen input injection on that desktop, so it belongs
306
+ wherever you'd keep a password, and the decision to store it at all belongs
307
+ to the application rather than to a library.
308
+
309
+ ## Sending input
310
+
311
+ Every burst of input is wrapped in `start_emulating()` … `frame()` …
312
+ `stop_emulating()`. `frame()` is what actually commits the queued events as one
313
+ logical hardware event; without it nothing is delivered. Each method returns
314
+ the device, so they chain.
315
+
316
+ ```python
317
+ # Move the pointer 10px right, 5px down
318
+ device.start_emulating().pointer_motion(10, 5).frame().stop_emulating()
319
+
320
+ # Left click (BTN_LEFT; codes are Linux input codes, from
321
+ # <linux/input-event-codes.h>)
322
+ BTN_LEFT = 0x110
323
+ device.start_emulating()
324
+ device.button(BTN_LEFT, True).frame()
325
+ device.button(BTN_LEFT, False).frame()
326
+ device.stop_emulating()
327
+
328
+ # Press the A key (KEY_A -- a key *position*, not the character
329
+ # "a"; see "Keys are positions, not characters" below)
330
+ KEY_A = 30
331
+ device.start_emulating()
332
+ device.keyboard_key(KEY_A, True).frame()
333
+ device.keyboard_key(KEY_A, False).frame()
334
+ device.stop_emulating()
335
+
336
+ # Scroll: smooth (logical pixels) or discrete (one detent is 120)
337
+ device.start_emulating().scroll_delta(0, 20).frame().stop_emulating()
338
+ device.start_emulating().scroll_discrete(0, 120).frame().stop_emulating()
339
+ ```
340
+
341
+ ### Keys are positions, not characters
342
+
343
+ `keyboard_key()` takes a Linux evdev keycode — a *physical key position*, not
344
+ a character. `KEY_A = 30` means "the key where A sits on a US QWERTY board";
345
+ under Dvorak or AZERTY the compositor turns that same code into a different
346
+ character. There is no `type("hello")` and no keysym mapping here, so shifted
347
+ characters mean sending the modifier yourself:
348
+
349
+ ```python
350
+ KEY_LEFTSHIFT, KEY_A = 42, 30
351
+ device.start_emulating()
352
+ device.keyboard_key(KEY_LEFTSHIFT, True).frame()
353
+ device.keyboard_key(KEY_A, True).frame() # "A", not "a"
354
+ device.keyboard_key(KEY_A, False).frame()
355
+ device.keyboard_key(KEY_LEFTSHIFT, False).frame()
356
+ device.stop_emulating()
357
+ ```
358
+
359
+ To get this right for whatever layout the user actually has, read the keymap
360
+ the compositor handed you and resolve characters through it — with the
361
+ `xkbcommon` bindings, say, which this package does not depend on:
362
+
363
+ ```python
364
+ keymap = device.keymap # None unless the device has KEYBOARD
365
+ if keymap is not None:
366
+ assert keymap.keymap_type is ei.KeymapType.XKB # the only type so far
367
+ with keymap.fd as f: # a fresh dup() each read; closing it is yours
368
+ data = f.read(keymap.size)
369
+ ```
370
+
371
+ `keymap.fd` duplicates libei's descriptor and rewinds the copy for you. Left
372
+ to itself a `dup()` shares the original's file offset, which libei leaves at
373
+ the end — reading through it returned zero bytes and no error, which is
374
+ indistinguishable from an empty keymap.
375
+
376
+ ### Or skip layouts entirely, on libei 1.6
377
+
378
+ A device with the `TEXT` capability takes characters directly, and the
379
+ compositor works out which keys that means under the active layout:
380
+
381
+ ```python
382
+ device.start_emulating().text_utf8("héllo").frame().stop_emulating()
383
+ device.start_emulating().text_keysym(0x61, True).frame().stop_emulating()
384
+ ```
385
+
386
+ This is the one path here that types text rather than pressing positions.
387
+ It needs libei 1.6 on both sides and a seat that offers
388
+ `DeviceCapability.TEXT`; on anything older, `text_utf8()` raises
389
+ `LibraryNotFoundError`, so keep the keycode path as a fallback.
390
+
391
+ Modifier *state* arrives as events rather than being queryable — watch for
392
+ `EventType.KEYBOARD_MODIFIERS` and read `event.keyboard_xkb_modifiers`, which
393
+ gives `depressed`, `latched`, `locked` and `group`. That is how you find out
394
+ the compositor thinks Caps Lock is on before you start injecting.
395
+
396
+ Absolute positioning needs the `POINTER_ABSOLUTE` capability, and coordinates
397
+ fall inside one of `device.regions`:
398
+
399
+ ```python
400
+ device.start_emulating().pointer_motion_absolute(960, 540).frame().stop_emulating()
401
+ ```
402
+
403
+ Regions carry more than bounds. `region.mapping_id` groups the ones that map
404
+ to the same thing, `device.region_at(x, y)` finds which region a point falls
405
+ in, and `region.convert_point(x, y)` turns a desktop-wide point into one
406
+ relative to that region — returning `None` when it falls outside, which also
407
+ answers "is it in here?" in a single call. All three need libei 1.1.
408
+
409
+ **Pick the device by capability, not by arrival order.** A seat can resume
410
+ more than one device — on GNOME you get *both* a relative `virtual pointer`
411
+ and an absolute `shared virtual absolute pointer`, and the relative one
412
+ arrives first. Reusing the quick-start's "first `DEVICE_RESUMED` wins" loop
413
+ here hands you the relative device, on which `pointer_motion_absolute()`
414
+ does nothing at all: no exception, no movement, just an internal libei
415
+ warning (`device is not an absolute pointer`, visible only if you turn on
416
+ [logging](#logging)). Wait for the one you need:
417
+
418
+ ```python
419
+ device = None
420
+ while device is None:
421
+ select.select([sender.fd], [], [], 5)
422
+ sender.dispatch()
423
+ for event in sender.events:
424
+ if event.event_type is ei.EventType.SEAT_ADDED:
425
+ event.seat.bind((
426
+ ei.DeviceCapability.POINTER_ABSOLUTE,
427
+ ei.DeviceCapability.BUTTON,
428
+ ))
429
+ elif event.event_type is ei.EventType.DEVICE_RESUMED:
430
+ if ei.DeviceCapability.POINTER_ABSOLUTE in event.device.capabilities:
431
+ device = event.device # skip the relative sibling
432
+ ```
433
+
434
+ Touch uses its own short-lived object rather than the device directly:
435
+
436
+ ```python
437
+ touch = device.touch_new()
438
+ device.start_emulating()
439
+ touch.down(100, 200)
440
+ device.frame()
441
+ touch.motion(150, 250)
442
+ device.frame()
443
+ touch.up() # or touch.cancel(), if the gesture was aborted
444
+ device.frame()
445
+ device.stop_emulating()
446
+ ```
447
+
448
+ A cancelled touch still reaches the other side as a `TOUCH_UP` event; what
449
+ separates it from a normal release is `event.touch_up_event.is_cancel`. Both
450
+ sides need version 2 or later of the `ei_touchscreen` interface, and against
451
+ anything older `cancel()` is a noop.
452
+
453
+ ## Things that will bite you
454
+
455
+ - **`dispatch()` before `events`.** `events` drains only what is already
456
+ queued; it yields nothing until `dispatch()` has read from the socket.
457
+ - **Don't keep an event past its loop iteration.** Each event is released as
458
+ soon as the loop moves on, and using it afterwards raises `RuntimeError`.
459
+ Objects you pull *off* an event (`event.device`, `event.seat`) are safe to
460
+ keep — copy out `event.pointer_event` and friends rather than the event.
461
+ - **`frame()` or nothing happens.** Events queue up until a `frame()` commits
462
+ them.
463
+ - **`bind()` needs at least one capability.** Binding an empty set sends
464
+ nothing, so the device you are waiting for never arrives; this raises
465
+ `ValueError` rather than hanging.
466
+ - **Capabilities are per-seat.** A seat only offers some; check
467
+ `seat.capabilities` before binding.
468
+ - **One seat can resume several devices.** Bind both `POINTER` and
469
+ `POINTER_ABSOLUTE` and GNOME gives you two, relative first. Taking
470
+ whichever resumes first is a coin flip — select on `device.capabilities`
471
+ instead. Sending an event the device lacks the capability for is silently
472
+ ignored, which makes this look like the injection simply not working.
473
+
474
+ - **Read the accessor that matches the event type.** `event.key_event` on a
475
+ `POINTER_MOTION` event raises `TypeError` naming both types. libei itself
476
+ would have returned `KeyEvent(key=0, is_press=False)` — a real-looking
477
+ value — while logging a `Bug:` line the caller never sees, so branch on
478
+ `event_type` first. `TOUCH_UP` has its own `touch_up_event`, since it
479
+ carries no coordinates.
480
+ - **`GESTURES` and `STYLUS` are not in any released libei.** They match
481
+ upstream `main` and are here ready for it, but 1.6.0's capability enum
482
+ stops at `TEXT`. Binding them against a shipping library silently does
483
+ nothing — no error, no device, no events.
484
+
485
+ ## Reading input instead of sending it
486
+
487
+ Use `ei.Receiver` in place of `ei.Sender` — same connection dance, but events
488
+ carry input *from* the compositor:
489
+
490
+ ```python
491
+ receiver = ei.Receiver.create_for_fd(eis_fd, name="my-app")
492
+ receiver.dispatch()
493
+ for event in receiver.events:
494
+ if event.event_type is ei.EventType.POINTER_MOTION:
495
+ motion = event.pointer_event
496
+ print(motion.dx, motion.dy)
497
+ ```
498
+
499
+ Each event type has its own getter — `key_event`, `button_event`,
500
+ `pointer_event`, `pointer_absolute_event`, `scroll_event`,
501
+ `scroll_discrete_event`, `scroll_stop_event`, `touch_event` and
502
+ `touch_up_event`, `text_utf8_event`, `text_keysym_event` and
503
+ `keyboard_xkb_modifiers` — and each checks the event's type before reading,
504
+ raising `TypeError` rather than handing back the zero-filled result libei
505
+ would give for a mismatch.
506
+
507
+ Gesture and stylus events have no accessor and cannot arrive from a
508
+ released libei at all (see [What's implemented](#whats-implemented)), but
509
+ they can be identified and skipped safely if they ever do. So can event
510
+ types this package has never heard of:
511
+ `event_type` returns a plain `int` rather than raising, because libei's own
512
+ header says the enum "is not exhaustive". To look at what is coming without
513
+ consuming it, `peek_event_type()` reports the next event's type and leaves it
514
+ queued.
515
+
516
+ ## Connection lifecycle
517
+
518
+ Beyond sending input, a long-lived client usually wants three things.
519
+
520
+ **Check the connection is alive.** A ping is a round trip that comes back as
521
+ a `PONG` event carrying the same object, so several can be in flight at once
522
+ and still be told apart:
523
+
524
+ ```python
525
+ ping = sender.new_ping()
526
+ ping.send()
527
+ # ... later, in the event loop:
528
+ # if event.event_type is ei.EventType.PONG and event.pong.id == ping.id:
529
+ # ...
530
+ ```
531
+
532
+ **Ask for another device.** If you closed one, or the ones the seat gave you
533
+ no longer cover what you need, `seat.request_device((cap, ...))` asks for
534
+ another — a subset of what `bind()` requested. The server may answer with
535
+ different capabilities, or not at all; anything it does create arrives as a
536
+ `DEVICE_ADDED` event. Needs libei 1.6.
537
+
538
+ **Shut down deliberately.** `sender.disconnect()` tears the session down
539
+ through the event queue: seats and devices are removed as though the server
540
+ had done it, and `DISCONNECT` is the last event you get. The context is inert
541
+ afterwards but still needs releasing like any other object. Needs libei 1.4.
542
+
543
+ ## Running your own EIS server
544
+
545
+ `libei.eis` is the compositor side of the protocol. Most people want it for
546
+ *testing* — it lets you drive the client code above without a real compositor
547
+ or a consent dialog:
548
+
549
+ ```python
550
+ import select
551
+ from libei import eis
552
+
553
+ server = eis.Eis.create_for_fd()
554
+ client_fd = server.add_client() # hand this fd to a client's ei.Sender/Receiver
555
+
556
+ while True:
557
+ select.select([server.fd], [], [])
558
+ server.dispatch() # events is empty until dispatch() reads the connection
559
+ for event in server.events:
560
+ if event.event_type is eis.EventType.CLIENT_CONNECT:
561
+ event.client.connect()
562
+ seat = event.client.new_seat("default")
563
+ seat.configure_capabilities((eis.DeviceCapability.POINTER,))
564
+ seat.add()
565
+ elif event.event_type is eis.EventType.SEAT_BIND:
566
+ device = event.seat.new_device()
567
+ device.configure(
568
+ name="my-pointer", capabilities=(eis.DeviceCapability.POINTER,)
569
+ )
570
+ device.add()
571
+ device.resume() # until you resume it, the client may not send
572
+ ```
573
+
574
+ `tests/test_integration_socketpair.py` is a complete, working version of both
575
+ halves — connect, negotiate, and round-trip a pointer motion, in one process
576
+ against the real library.
577
+
578
+ ## Logging
579
+
580
+ libei's own diagnostics are routed into Python's `logging` — the `libei.ei`,
581
+ `libei.eis` and `libei.oeffis` loggers — rather than being written to stderr
582
+ by the C library. This is how you see the warnings that otherwise look like
583
+ nothing happening at all, `device is not an absolute pointer` among them:
584
+
585
+ ```python
586
+ import logging
587
+ logging.basicConfig(level=logging.DEBUG)
588
+ logging.getLogger("libei").setLevel(logging.DEBUG)
589
+ ```
590
+
591
+ At `DEBUG` this is a full protocol trace (every object, message and
592
+ signature, both directions — a few hundred lines for a single connect and
593
+ one pointer motion), which makes it the first thing to reach for when a
594
+ negotiation stalls. `WARNING` gets you just libei's complaints.
595
+
596
+ ## Troubleshooting
597
+
598
+ **The portal dialog appears, I approve it, and nothing happens.** Earlier
599
+ testing on GNOME 44 saw the round trip hang indefinitely even after clicking
600
+ through the dialog, and it went unroot-caused for a while. Revisited
601
+ 2026-08-25 on GNOME 50.4 with a `busctl monitor` trace on the real portal:
602
+ `CreateSession -> SelectDevices -> Start -> ConnectToEIS` completed cleanly in
603
+ a few seconds, 3/3 consecutive attempts, with `Start()`'s `Response` signal
604
+ arriving only after a multi-second gap consistent with a real dialog being
605
+ answered. The code was correctly waiting the whole time -- `dispatch()`
606
+ returning `False` just means no `Response` has arrived yet.
607
+
608
+ The likely explanation for the earlier hangs: `RemoteDesktop.Start()` can
609
+ involve more than one prompt (an access-request dialog, then a device-sharing
610
+ confirmation), and dismissing or missing one leaves `Start()` never returning
611
+ -- indistinguishable from a hang on the caller's side. Not independently
612
+ confirmed by watching the dialogs themselves, only inferred from this trace
613
+ plus which step it stalled at previously; if you hit this again, check
614
+ whether a second prompt is waiting before assuming it's this library.
615
+ `libei.eis` remains the right fallback for anything that doesn't need the
616
+ portal at all, e.g. tests.
617
+
618
+ **`LibraryNotFoundError`.** The native library isn't installed, or is too old
619
+ to export a function this package binds. Check with `ei.is_available()`.
620
+
621
+ **My events never arrive.** Almost always a missing `frame()`, or emulating
622
+ before `DEVICE_RESUMED`, or a device that lacks the capability for the event
623
+ you're sending — all three fail silently. Turn on [logging](#logging) at
624
+ `DEBUG` to see what actually reached the compositor.
625
+
626
+ **The loop hangs waiting for a device.** Check that the seat actually offers
627
+ the capability you bound (`seat.capabilities`).
628
+
629
+ ## API summary
630
+
631
+ | Module | Use it for |
632
+ | --- | --- |
633
+ | `libei.ei` | Clients: `Sender` (inject), `Receiver` (consume) |
634
+ | `libei.eis` | Servers: `Eis`, for compositors and for testing clients |
635
+ | `libei.oeffis` | Getting an EI fd from the desktop portal |
636
+
637
+ Each module has `is_available()`, an `Error` exception, and an `EventType` /
638
+ `DeviceCapability` enum. `ei` and `eis` also share the shapes around them:
639
+ `Device`, `Seat`, `Region`, `Keymap`, `Touch`, `Ping`, `Event`, and the frozen
640
+ dataclasses its accessors return. The package ships `py.typed`, so callers
641
+ type-check against real annotations rather than `Any`.
642
+
643
+ For which of libei's capabilities are actually driveable, and which C
644
+ functions are left unbound, see [What's implemented](#whats-implemented).
645
+
646
+ ## Development
647
+
648
+ ### Architecture
649
+
650
+ Four layers, bottom up. If you're reading the code for the first time, read
651
+ them in this order -- each one only makes sense once the one below it does.
652
+
653
+ | Layer | What it does |
654
+ | --- | --- |
655
+ | [`_capi/loader.py`](src/libei/_capi/loader.py) | `LazyLibrary`: `dlopen`s a `.so` on first *call*, not at import, so this package imports fine with no native libraries installed |
656
+ | [`_capi/libei.py`](src/libei/_capi/libei.py), `libeis.py`, `liboeffis.py` | One line per C function, with hand-written ctypes signatures. Nothing else -- no logic |
657
+ | [`_cobject.py`](src/libei/_cobject.py) | `CObject`: pointer ownership, refcounting, and the identity cache that every wrapper class inherits |
658
+ | [`ei.py`](src/libei/ei.py), [`eis.py`](src/libei/eis.py), [`oeffis.py`](src/libei/oeffis.py) | The public API: Python classes, enums and dataclasses over the raw calls |
659
+
660
+ **Read `_cobject.py` first.** It is the smallest file with the most
661
+ consequence: get `wrap()` vs `adopt()`, the `staticmethod()` wrapping of
662
+ `_ref_func`/`_unref_func`, or the `_wrappable` flag wrong and the failure is
663
+ a use-after-free or a segfault rather than a traceback. Every non-obvious
664
+ line there carries a comment explaining what breaks without it.
665
+
666
+ Two conventions worth knowing before the code reads cleanly:
667
+
668
+ - **`_capi` names drop the C prefix.** `ei_unref()` is
669
+ `_capi.libei.unref()`, `eis_device_configure_name()` is
670
+ `_capi.libeis.device_configure_name()`. The module says which library it
671
+ is, so repeating it in every name would only add noise.
672
+ - **Wrapper instances are passed straight to C calls.** `CObject` defines
673
+ `_as_parameter_`, which ctypes consults automatically, so
674
+ `_capi.libei.device_frame(self, timestamp)` works without unwrapping a
675
+ pointer out of `self` by hand.
676
+
677
+ ### Setup and checks
678
+
679
+ ```
680
+ python -m venv .venv && . .venv/bin/activate
681
+ pip install -e '.[dev]'
682
+
683
+ ruff check src tests
684
+ python -m mypy
685
+
686
+ pytest # everything; the integration tests below
687
+ # skip themselves if libei/libeis is absent
688
+ pytest -m integration # only the tests that drive the real libraries
689
+ pytest -rs # ...and report which tests skipped, and why
690
+ ```
691
+
692
+ Tests for a feature the installed libei is too old to provide skip
693
+ themselves by checking for the symbol before they negotiate anything --
694
+ `tests/conftest.py`'s `requires_symbol()`. Checking up front rather than
695
+ catching the failure matters: a capability an older library has never heard
696
+ of is accepted silently and simply yields no device, so a test that waited
697
+ for one would hang to its timeout instead of skipping.
698
+
699
+ CI runs the suite on Python 3.10-3.13 against Ubuntu's libei, which is
700
+ 1.2.1 -- deliberately older than the 1.6.0 used for development, so the
701
+ 1.0.0 core floor and the version gates both get exercised on a real build
702
+ rather than only on paper. To reproduce that locally, build an old libei
703
+ and point the loader at it:
704
+
705
+ ```sh
706
+ git clone --depth 1 --branch 1.2.1 \
707
+ https://gitlab.freedesktop.org/libinput/libei.git
708
+ cd libei && meson setup build -Dtests=disabled -Ddocumentation=[] \
709
+ --prefix=$PWD/prefix && ninja -C build install
710
+ LD_LIBRARY_PATH=$PWD/prefix/lib64 pytest -q -rs # from this checkout
711
+ ```
712
+
713
+ Expect passes plus skips, never failures or hangs.
714
+
715
+ A separate job installs the package with no native libraries at all and
716
+ imports it, which is the property the lazy loader exists to provide.
717
+
718
+ ### Releasing
719
+
720
+ Versions are SemVer and live in two places -- `pyproject.toml` and
721
+ `src/libei/__init__.py` -- which have to agree with each other and with the
722
+ tag. Nothing enforces that yet.
723
+
724
+ A release is an annotated, `v`-prefixed tag plus a GitHub Release:
725
+
726
+ ```sh
727
+ git tag -a v0.1.0 -m "0.1.0"
728
+ git push origin v0.1.0
729
+ gh release create v0.1.0 --generate-notes --prerelease
730
+ ```
731
+
732
+ `--prerelease` while the API is unfrozen -- it keeps an alpha out of the
733
+ "Latest release" slot.
734
+
735
+ Publishing runs from CI on a `v*` tag using PyPI
736
+ [Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (OIDC), so
737
+ there is no API token in repository secrets to leak or rotate. The `publish`
738
+ job in `ci.yml` handles it, uploading the artifacts the `build` job already
739
+ ran `twine check` over.
740
+
741
+ That job depends on two pieces of configuration outside this repository,
742
+ which have to exist before the first tag:
743
+
744
+ 1. On pypi.org, under Account settings -> Publishing, a **pending**
745
+ publisher -- the flow for a project that has no releases yet. Project
746
+ `python-libei`, owner `ctrondlp`, repository `python-libei`, workflow
747
+ `ci.yml`, environment `pypi`. Every field has to match the workflow
748
+ exactly; a mismatch surfaces as a rejected credential at upload time,
749
+ not when it is saved.
750
+ 2. A GitHub environment named `pypi`, in the repository settings. A
751
+ required reviewer on it makes each publish a deliberate approval rather
752
+ than a side effect of pushing a tag.
753
+
754
+ Worth rehearsing on TestPyPI first: separate account, separate pending
755
+ publisher, and `repository-url: https://test.pypi.org/legacy/` on the
756
+ publish step. PyPI filenames are immutable, so a bad upload can only be
757
+ yanked and superseded by a new version, never replaced.
758
+
759
+ ## Design notes
760
+
761
+ Written from scratch, taking its overall shape from
762
+ [snegg](https://gitlab.freedesktop.org/whot/snegg) (the reference bindings by
763
+ libei's own author), with different priorities suited to being embedded as a
764
+ dependency rather than used for prototyping.
765
+ [`docs/vs-snegg.md`](docs/vs-snegg.md) covers the specifics, including two
766
+ signature issues found by cross-checking against the real libei source.
767
+
768
+ ## License
769
+
770
+ MIT.