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