python-libei 0.5.2__tar.gz → 0.6.1__tar.gz

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.
Files changed (38) hide show
  1. {python_libei-0.5.2/src/python_libei.egg-info → python_libei-0.6.1}/PKG-INFO +58 -28
  2. python_libei-0.5.2/PKG-INFO → python_libei-0.6.1/README.md +52 -62
  3. {python_libei-0.5.2 → python_libei-0.6.1}/pyproject.toml +15 -2
  4. {python_libei-0.5.2 → python_libei-0.6.1}/src/libei/__init__.py +1 -1
  5. {python_libei-0.5.2 → python_libei-0.6.1}/src/libei/_capi/libei.py +5 -3
  6. {python_libei-0.5.2 → python_libei-0.6.1}/src/libei/_capi/libeis.py +6 -4
  7. {python_libei-0.5.2 → python_libei-0.6.1}/src/libei/_capi/loader.py +41 -1
  8. {python_libei-0.5.2 → python_libei-0.6.1}/src/libei/_cobject.py +6 -0
  9. {python_libei-0.5.2 → python_libei-0.6.1}/src/libei/ei.py +19 -0
  10. {python_libei-0.5.2 → python_libei-0.6.1}/src/libei/eis.py +17 -0
  11. {python_libei-0.5.2 → python_libei-0.6.1}/src/libei/oeffis.py +12 -1
  12. {python_libei-0.5.2 → python_libei-0.6.1}/src/libei/portal.py +26 -9
  13. python_libei-0.5.2/README.md → python_libei-0.6.1/src/python_libei.egg-info/PKG-INFO +92 -25
  14. {python_libei-0.5.2 → python_libei-0.6.1}/src/python_libei.egg-info/SOURCES.txt +4 -0
  15. python_libei-0.6.1/tests/test_abi.py +496 -0
  16. {python_libei-0.5.2 → python_libei-0.6.1}/tests/test_documentation_shape.py +167 -14
  17. {python_libei-0.5.2 → python_libei-0.6.1}/tests/test_documented_examples.py +0 -27
  18. {python_libei-0.5.2 → python_libei-0.6.1}/tests/test_ei_objects.py +4 -0
  19. {python_libei-0.5.2 → python_libei-0.6.1}/tests/test_eis_objects.py +4 -0
  20. python_libei-0.6.1/tests/test_event_accessors.py +177 -0
  21. {python_libei-0.5.2 → python_libei-0.6.1}/tests/test_integration_extras.py +4 -0
  22. python_libei-0.6.1/tests/test_integration_frame.py +104 -0
  23. python_libei-0.6.1/tests/test_integration_lifetime.py +49 -0
  24. python_libei-0.6.1/tests/test_loader.py +198 -0
  25. {python_libei-0.5.2 → python_libei-0.6.1}/tests/test_portal.py +14 -0
  26. python_libei-0.5.2/tests/test_loader.py +0 -81
  27. {python_libei-0.5.2 → python_libei-0.6.1}/LICENSE +0 -0
  28. {python_libei-0.5.2 → python_libei-0.6.1}/setup.cfg +0 -0
  29. {python_libei-0.5.2 → python_libei-0.6.1}/src/libei/_capi/__init__.py +0 -0
  30. {python_libei-0.5.2 → python_libei-0.6.1}/src/libei/_capi/liboeffis.py +0 -0
  31. {python_libei-0.5.2 → python_libei-0.6.1}/src/libei/py.typed +0 -0
  32. {python_libei-0.5.2 → python_libei-0.6.1}/src/python_libei.egg-info/dependency_links.txt +0 -0
  33. {python_libei-0.5.2 → python_libei-0.6.1}/src/python_libei.egg-info/requires.txt +0 -0
  34. {python_libei-0.5.2 → python_libei-0.6.1}/src/python_libei.egg-info/top_level.txt +0 -0
  35. {python_libei-0.5.2 → python_libei-0.6.1}/tests/test_cobject.py +0 -0
  36. {python_libei-0.5.2 → python_libei-0.6.1}/tests/test_inputcapture.py +0 -0
  37. {python_libei-0.5.2 → python_libei-0.6.1}/tests/test_integration_socketpair.py +0 -0
  38. {python_libei-0.5.2 → python_libei-0.6.1}/tests/test_oeffis.py +0 -0
@@ -1,18 +1,21 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-libei
3
- Version: 0.5.2
4
- Summary: Inject and receive input on Wayland from Python: ctypes bindings for libei, libeis and liboeffis
3
+ Version: 0.6.1
4
+ Summary: Inject, receive and capture input on Wayland from Python: ctypes bindings for libei, libeis and liboeffis, plus the RemoteDesktop and InputCapture portals
5
5
  Author: Dennis K. Paulsen
6
6
  License-Expression: MIT
7
7
  Project-URL: homepage, https://github.com/ctrondlp/python-libei
8
8
  Project-URL: repository, https://github.com/ctrondlp/python-libei
9
9
  Project-URL: issues, https://github.com/ctrondlp/python-libei/issues
10
+ Project-URL: documentation, https://github.com/ctrondlp/python-libei/tree/main/docs
11
+ Project-URL: pyguitest, https://github.com/ctrondlp/pyguitest
10
12
  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
13
+ Keywords: wayland,libei,libeis,liboeffis,input,input-emulation,emulated-input,input-capture,eis,portal,xdg-desktop-portal,remote-desktop,automation,gui-testing,accessibility,ctypes
12
14
  Classifier: Development Status :: 4 - Beta
13
15
  Classifier: Intended Audience :: Developers
14
16
  Classifier: Operating System :: POSIX :: BSD :: FreeBSD
15
17
  Classifier: Operating System :: POSIX :: Linux
18
+ Classifier: Operating System :: POSIX
16
19
  Classifier: Programming Language :: Python :: 3
17
20
  Classifier: Programming Language :: Python :: 3 :: Only
18
21
  Classifier: Programming Language :: Python :: 3.10
@@ -37,6 +40,10 @@ Dynamic: license-file
37
40
 
38
41
  # python-libei
39
42
 
43
+ [![CI](https://github.com/ctrondlp/python-libei/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/ctrondlp/python-libei/actions/workflows/ci.yml)
44
+ [![PyPI](https://img.shields.io/pypi/v/python-libei)](https://pypi.org/project/python-libei/)
45
+ [![License](https://img.shields.io/pypi/l/python-libei)](https://github.com/ctrondlp/python-libei/blob/main/LICENSE)
46
+
40
47
  Python bindings for [libei, libeis and liboeffis](https://libinput.pages.freedesktop.org/libei/) —
41
48
  the Wayland input-emulation libraries. Use this to **move the pointer, click,
42
49
  type, or scroll on a Wayland desktop** from Python, the way `xdotool` did on
@@ -49,8 +56,11 @@ device.start_emulating().pointer_motion(5, 0).frame().stop_emulating()
49
56
  ```
50
57
 
51
58
  **The one concept:** events queue up, and **`frame()` is what sends them** as
52
- one logical hardware event. Forget it and nothing happens — no exception, no
53
- warning, no movement. Fill the package, then post it.
59
+ one logical hardware event. Forget it and the motion is usually lost — no
60
+ exception, no warning, no movement. (Ending the chain with `stop_emulating()`
61
+ makes libei frame the queue for you, but it logs an error-level `Bug:` line
62
+ when it does; see [Things that will bite you](#things-that-will-bite-you).) Fill
63
+ the package, then post it.
54
64
 
55
65
  New here? [docs/getting-started.md](docs/getting-started.md) is install
56
66
  through a first real pointer motion, in five minutes.
@@ -92,8 +102,8 @@ than a widget-tree back door:
92
102
 
93
103
  ## Which API do I need?
94
104
 
95
- Five modules, and most callers need exactly two of them: `oeffis` or `portal`
96
- to get permission, then `ei` to inject.
105
+ Four modules (`ei`, `eis`, `oeffis`, `portal`), and most callers need exactly
106
+ two of them: `oeffis` or `portal` to get permission, then `ei` to inject.
97
107
 
98
108
  | You want to… | Use | Notes |
99
109
  | --- | --- | --- |
@@ -101,13 +111,18 @@ to get permission, then `ei` to inject.
101
111
  | **Consume input** from a compositor | `libei.ei` → `Receiver` | Compositor-side or input-capture code; same connection dance |
102
112
  | **Get permission**, simply | `libei.oeffis` | One call, pollable fd, no dependencies. The consent dialog appears on **every** run |
103
113
  | **Get permission**, and not be asked again | `libei.portal` | Same handshake over D-Bus directly, with `persist_mode` / `restore_token`. Needs PyGObject |
114
+ | **Capture the user's real input** | `libei.portal` → `InputCaptureSession` | The read direction, through the InputCapture portal: once the compositor decides to, the user's pointer and keyboard are diverted to you over EIS. Exclusive while active, so hold it briefly. Only its negotiation half has been run against a real desktop — see [verification](docs/developers/verification.md) |
104
115
  | **Be the server**, for tests or a compositor | `libei.eis` | Drives your client code with no real compositor and no consent dialog |
105
116
 
106
- Each module has `is_available()`, an `Error` exception, and an `EventType` /
107
- `DeviceCapability` enum. `ei` and `eis` also share the shapes around them:
108
- `Device`, `Seat`, `Region`, `Keymap`, `Touch`, `Ping`, `Event`, and the frozen
109
- dataclasses its accessors return. The package ships `py.typed`, so callers
110
- type-check against real annotations rather than `Any`.
117
+ Each module has `is_available()`. `ei` and `eis` share the shapes around them:
118
+ an `Error` exception, an `EventType` and `DeviceCapability` enum, and `Device`,
119
+ `Seat`, `Region`, `Keymap`, `Touch`, `Ping`, `Event` with the frozen dataclasses
120
+ its accessors return. `oeffis` and `portal` are smaller — `DeviceType`, no
121
+ `EventType` or `DeviceCapability`, `Activation` as the one frozen result a
122
+ portal wait hands back, and their own exception classes rather than an `Error`.
123
+ [docs/troubleshooting.md](docs/troubleshooting.md) names every class they raise.
124
+ The package ships `py.typed`, so callers type-check against real
125
+ annotations rather than `Any`.
111
126
 
112
127
  ## What's implemented
113
128
 
@@ -135,8 +150,9 @@ because a skimming reader could otherwise take them as supported. 1.6.0's own
135
150
  `enum ei_device_capability` stops at `TEXT`, and its `enum ei_event_type`
136
151
  stops at `EI_EVENT_TEXT_UTF8` — so the swipe/pinch/hold/stylus members of
137
152
  `EventType` cannot arrive either. The values here match upstream `main`
138
- exactly, so they are ready for whatever release adds them. The 22
139
- gesture/stylus accessor functions `main` adds are deliberately not bound:
153
+ exactly, so they are ready for whatever release adds them. The gesture
154
+ and stylus functions `main` adds -- 46 in libei and 51 in libeis against
155
+ 1.6.0's headers, counted 2026-09-30 -- are deliberately not bound:
140
156
  nothing that ships today exports them, so nothing here could be verified
141
157
  against a real library, which is the bar every other binding in this package
142
158
  was held to.
@@ -149,14 +165,16 @@ Beyond sending input, the wrapper also covers ping/pong round trips
149
165
  (`Device.keymap`), region mapping ids and coordinate conversion,
150
166
  `Context.disconnect()`, `Context.peek_event_type()`, and
151
167
  `Seat.request_device()`. On the server side, `libei.eis` mirrors all of it and
152
- adds `Eis.set_flag()` and `Client.pid`. Underneath, the ctypes layer binds 250
168
+ adds `Eis.set_flag()` (with the `Flag` values it takes), `Client.pid`, and
169
+ `Device.configure()` with the `ConfigureRegion` descriptions it accepts.
170
+ Underneath, the ctypes layer binds 250
153
171
  of the 302 functions the three libraries export as of 1.6.0; what is left out,
154
172
  and why, is in
155
173
  [docs/developers/architecture.md](docs/developers/architecture.md#what-is-bound-and-what-is-deliberately-not).
156
174
 
157
175
  ## Status
158
176
 
159
- Beta (`0.5.2`), published on [PyPI](https://pypi.org/project/python-libei/)
177
+ Beta (`0.6.1`), published on [PyPI](https://pypi.org/project/python-libei/)
160
178
  since `0.1.0`, and **the API is not frozen** — expect renames before 1.0.
161
179
 
162
180
  The injection path is exercised end to end against the real libraries by the
@@ -197,9 +215,13 @@ Exactly what was run, when, and against which versions:
197
215
  - libei 1.0.0 or newer for the core: connecting, binding a seat, and
198
216
  sending pointer, button, keyboard, scroll and touch input all use symbols
199
217
  that have existed with a stable signature since 1.0.0, and upstream keeps
200
- API/ABI back-compatible within the 1.x series. Only 1.5.0 and 1.6.0 have
201
- actually been run against -- 1.6.0 on both Fedora 44 and FreeBSD 15, where
202
- the injection path passes the full suite with nothing skipped.
218
+ API/ABI back-compatible within the 1.x series. The suite has been run
219
+ against 1.2.1 (Ubuntu 24.04, in CI) and 1.6.0 -- the latter on Fedora 44 and
220
+ 45 and FreeBSD 15, where the injection path passes the full suite with
221
+ nothing skipped. Every binding's name, argument types and return type, and
222
+ every enum value, is also checked against the upstream headers of 1.0.0,
223
+ 1.2.1, 1.4.0, 1.5.0 and 1.6.0 (`tests/test_abi.py`), which is the only
224
+ coverage 1.0.0, 1.4.0 and 1.5.0 have had.
203
225
 
204
226
  Newer libei buys you more, per feature:
205
227
 
@@ -357,8 +379,12 @@ All four, with working code:
357
379
  soon as the loop moves on, and using it afterwards raises `RuntimeError`.
358
380
  Objects you pull *off* an event (`event.device`, `event.seat`) are safe to
359
381
  keep — copy out `event.pointer_event` and friends rather than the event.
360
- - **`frame()` or nothing happens.** Events queue up until a `frame()` commits
361
- them.
382
+ - **`frame()` or the input is lost.** Events queue up until a `frame()` commits
383
+ them, and queued events that are never framed are dropped without an
384
+ exception or a log line. The one exception is a chain that ends in
385
+ `stop_emulating()`: libei frames what is queued, delivers it, and logs
386
+ `Bug: ei_device_stop_emulating: missing call to ei_device_frame()` at error
387
+ level. Measured against libei 1.2.1 and 1.6.0; don't rely on it.
362
388
  - **`bind()` needs at least one capability.** Binding an empty set sends
363
389
  nothing, so the device you are waiting for never arrives; this raises
364
390
  `ValueError` rather than hanging.
@@ -367,20 +393,22 @@ All four, with working code:
367
393
  - **One seat can resume several devices.** Bind both `POINTER` and
368
394
  `POINTER_ABSOLUTE` and GNOME gives you two, relative first. Taking
369
395
  whichever resumes first is a coin flip — select on `device.capabilities`
370
- instead. Sending an event the device lacks the capability for is silently
371
- ignored, which makes this look like the injection simply not working.
396
+ instead. Sending an event the device lacks the capability for is dropped with
397
+ no exception — libei only logs an error-level `Bug: ... device is not a
398
+ keyboard` line — which makes this look like the injection simply not working.
372
399
  - **Read the accessor that matches the event type.** `event.key_event` on a
373
400
  `POINTER_MOTION` event raises `TypeError` naming both types. libei itself
374
401
  would have returned `KeyEvent(key=0, is_press=False)` — a real-looking
375
- value — while logging a `Bug:` line the caller never sees, so branch on
376
- `event_type` first. `TOUCH_UP` has its own `touch_up_event`, since it
402
+ value — and raised nothing, only logging a `Bug:` line at error level, so
403
+ branch on `event_type` first. `TOUCH_UP` has its own `touch_up_event`, since it
377
404
  carries no coordinates.
378
405
  - **`GESTURES` and `STYLUS` are not in any released libei.** They match
379
406
  upstream `main` and are here ready for it, but 1.6.0's capability enum
380
407
  stops at `TEXT`. Binding them against a shipping library silently does
381
408
  nothing — no error, no device, no events.
382
409
 
383
- Nearly all of these fail *silently*, which is why
410
+ Nearly all of these fail without raising — some only log an error-level `Bug:`
411
+ line, some not even that — which is why
384
412
  [docs/troubleshooting.md](docs/troubleshooting.md) is a checklist rather than
385
413
  a list of error messages. Start there when nothing happens.
386
414
 
@@ -394,8 +422,10 @@ a list of error messages. Start there when nothing happens.
394
422
  happens" checklist
395
423
  - [docs/vs-snegg.md](docs/vs-snegg.md) — how this differs from the reference
396
424
  bindings, and two signature issues found by cross-checking the C source
397
- - [docs/developers/](docs/developers/) — the four-layer architecture, and what
398
- has actually been verified against which libei versions
425
+ - [docs/developers/architecture.md](docs/developers/architecture.md) and
426
+ [docs/developers/verification.md](docs/developers/verification.md) — the
427
+ four-layer architecture, and what has actually been verified against which
428
+ libei versions
399
429
  - [CONTRIBUTING.md](CONTRIBUTING.md) — setup, checks, testing against an old
400
430
  libei, releasing
401
431
 
@@ -1,42 +1,9 @@
1
- Metadata-Version: 2.4
2
- Name: python-libei
3
- Version: 0.5.2
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 :: BSD :: FreeBSD
15
- Classifier: Operating System :: POSIX :: Linux
16
- Classifier: Programming Language :: Python :: 3
17
- Classifier: Programming Language :: Python :: 3 :: Only
18
- Classifier: Programming Language :: Python :: 3.10
19
- Classifier: Programming Language :: Python :: 3.11
20
- Classifier: Programming Language :: Python :: 3.12
21
- Classifier: Programming Language :: Python :: 3.13
22
- Classifier: Topic :: Desktop Environment
23
- Classifier: Topic :: Software Development :: Libraries
24
- Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
- Classifier: Topic :: Software Development :: Testing
26
- Classifier: Typing :: Typed
27
- Requires-Python: >=3.10
28
- Description-Content-Type: text/markdown
29
- License-File: LICENSE
30
- Provides-Extra: portal
31
- Requires-Dist: PyGObject>=3.42; extra == "portal"
32
- Provides-Extra: dev
33
- Requires-Dist: pytest>=7; extra == "dev"
34
- Requires-Dist: ruff>=0.6; extra == "dev"
35
- Requires-Dist: mypy>=1.11; extra == "dev"
36
- Dynamic: license-file
37
-
38
1
  # python-libei
39
2
 
3
+ [![CI](https://github.com/ctrondlp/python-libei/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/ctrondlp/python-libei/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/python-libei)](https://pypi.org/project/python-libei/)
5
+ [![License](https://img.shields.io/pypi/l/python-libei)](https://github.com/ctrondlp/python-libei/blob/main/LICENSE)
6
+
40
7
  Python bindings for [libei, libeis and liboeffis](https://libinput.pages.freedesktop.org/libei/) —
41
8
  the Wayland input-emulation libraries. Use this to **move the pointer, click,
42
9
  type, or scroll on a Wayland desktop** from Python, the way `xdotool` did on
@@ -49,8 +16,11 @@ device.start_emulating().pointer_motion(5, 0).frame().stop_emulating()
49
16
  ```
50
17
 
51
18
  **The one concept:** events queue up, and **`frame()` is what sends them** as
52
- one logical hardware event. Forget it and nothing happens — no exception, no
53
- warning, no movement. Fill the package, then post it.
19
+ one logical hardware event. Forget it and the motion is usually lost — no
20
+ exception, no warning, no movement. (Ending the chain with `stop_emulating()`
21
+ makes libei frame the queue for you, but it logs an error-level `Bug:` line
22
+ when it does; see [Things that will bite you](#things-that-will-bite-you).) Fill
23
+ the package, then post it.
54
24
 
55
25
  New here? [docs/getting-started.md](docs/getting-started.md) is install
56
26
  through a first real pointer motion, in five minutes.
@@ -92,8 +62,8 @@ than a widget-tree back door:
92
62
 
93
63
  ## Which API do I need?
94
64
 
95
- Five modules, and most callers need exactly two of them: `oeffis` or `portal`
96
- to get permission, then `ei` to inject.
65
+ Four modules (`ei`, `eis`, `oeffis`, `portal`), and most callers need exactly
66
+ two of them: `oeffis` or `portal` to get permission, then `ei` to inject.
97
67
 
98
68
  | You want to… | Use | Notes |
99
69
  | --- | --- | --- |
@@ -101,13 +71,18 @@ to get permission, then `ei` to inject.
101
71
  | **Consume input** from a compositor | `libei.ei` → `Receiver` | Compositor-side or input-capture code; same connection dance |
102
72
  | **Get permission**, simply | `libei.oeffis` | One call, pollable fd, no dependencies. The consent dialog appears on **every** run |
103
73
  | **Get permission**, and not be asked again | `libei.portal` | Same handshake over D-Bus directly, with `persist_mode` / `restore_token`. Needs PyGObject |
74
+ | **Capture the user's real input** | `libei.portal` → `InputCaptureSession` | The read direction, through the InputCapture portal: once the compositor decides to, the user's pointer and keyboard are diverted to you over EIS. Exclusive while active, so hold it briefly. Only its negotiation half has been run against a real desktop — see [verification](docs/developers/verification.md) |
104
75
  | **Be the server**, for tests or a compositor | `libei.eis` | Drives your client code with no real compositor and no consent dialog |
105
76
 
106
- Each module has `is_available()`, an `Error` exception, and an `EventType` /
107
- `DeviceCapability` enum. `ei` and `eis` also share the shapes around them:
108
- `Device`, `Seat`, `Region`, `Keymap`, `Touch`, `Ping`, `Event`, and the frozen
109
- dataclasses its accessors return. The package ships `py.typed`, so callers
110
- type-check against real annotations rather than `Any`.
77
+ Each module has `is_available()`. `ei` and `eis` share the shapes around them:
78
+ an `Error` exception, an `EventType` and `DeviceCapability` enum, and `Device`,
79
+ `Seat`, `Region`, `Keymap`, `Touch`, `Ping`, `Event` with the frozen dataclasses
80
+ its accessors return. `oeffis` and `portal` are smaller — `DeviceType`, no
81
+ `EventType` or `DeviceCapability`, `Activation` as the one frozen result a
82
+ portal wait hands back, and their own exception classes rather than an `Error`.
83
+ [docs/troubleshooting.md](docs/troubleshooting.md) names every class they raise.
84
+ The package ships `py.typed`, so callers type-check against real
85
+ annotations rather than `Any`.
111
86
 
112
87
  ## What's implemented
113
88
 
@@ -135,8 +110,9 @@ because a skimming reader could otherwise take them as supported. 1.6.0's own
135
110
  `enum ei_device_capability` stops at `TEXT`, and its `enum ei_event_type`
136
111
  stops at `EI_EVENT_TEXT_UTF8` — so the swipe/pinch/hold/stylus members of
137
112
  `EventType` cannot arrive either. The values here match upstream `main`
138
- exactly, so they are ready for whatever release adds them. The 22
139
- gesture/stylus accessor functions `main` adds are deliberately not bound:
113
+ exactly, so they are ready for whatever release adds them. The gesture
114
+ and stylus functions `main` adds -- 46 in libei and 51 in libeis against
115
+ 1.6.0's headers, counted 2026-09-30 -- are deliberately not bound:
140
116
  nothing that ships today exports them, so nothing here could be verified
141
117
  against a real library, which is the bar every other binding in this package
142
118
  was held to.
@@ -149,14 +125,16 @@ Beyond sending input, the wrapper also covers ping/pong round trips
149
125
  (`Device.keymap`), region mapping ids and coordinate conversion,
150
126
  `Context.disconnect()`, `Context.peek_event_type()`, and
151
127
  `Seat.request_device()`. On the server side, `libei.eis` mirrors all of it and
152
- adds `Eis.set_flag()` and `Client.pid`. Underneath, the ctypes layer binds 250
128
+ adds `Eis.set_flag()` (with the `Flag` values it takes), `Client.pid`, and
129
+ `Device.configure()` with the `ConfigureRegion` descriptions it accepts.
130
+ Underneath, the ctypes layer binds 250
153
131
  of the 302 functions the three libraries export as of 1.6.0; what is left out,
154
132
  and why, is in
155
133
  [docs/developers/architecture.md](docs/developers/architecture.md#what-is-bound-and-what-is-deliberately-not).
156
134
 
157
135
  ## Status
158
136
 
159
- Beta (`0.5.2`), published on [PyPI](https://pypi.org/project/python-libei/)
137
+ Beta (`0.6.1`), published on [PyPI](https://pypi.org/project/python-libei/)
160
138
  since `0.1.0`, and **the API is not frozen** — expect renames before 1.0.
161
139
 
162
140
  The injection path is exercised end to end against the real libraries by the
@@ -197,9 +175,13 @@ Exactly what was run, when, and against which versions:
197
175
  - libei 1.0.0 or newer for the core: connecting, binding a seat, and
198
176
  sending pointer, button, keyboard, scroll and touch input all use symbols
199
177
  that have existed with a stable signature since 1.0.0, and upstream keeps
200
- API/ABI back-compatible within the 1.x series. Only 1.5.0 and 1.6.0 have
201
- actually been run against -- 1.6.0 on both Fedora 44 and FreeBSD 15, where
202
- the injection path passes the full suite with nothing skipped.
178
+ API/ABI back-compatible within the 1.x series. The suite has been run
179
+ against 1.2.1 (Ubuntu 24.04, in CI) and 1.6.0 -- the latter on Fedora 44 and
180
+ 45 and FreeBSD 15, where the injection path passes the full suite with
181
+ nothing skipped. Every binding's name, argument types and return type, and
182
+ every enum value, is also checked against the upstream headers of 1.0.0,
183
+ 1.2.1, 1.4.0, 1.5.0 and 1.6.0 (`tests/test_abi.py`), which is the only
184
+ coverage 1.0.0, 1.4.0 and 1.5.0 have had.
203
185
 
204
186
  Newer libei buys you more, per feature:
205
187
 
@@ -357,8 +339,12 @@ All four, with working code:
357
339
  soon as the loop moves on, and using it afterwards raises `RuntimeError`.
358
340
  Objects you pull *off* an event (`event.device`, `event.seat`) are safe to
359
341
  keep — copy out `event.pointer_event` and friends rather than the event.
360
- - **`frame()` or nothing happens.** Events queue up until a `frame()` commits
361
- them.
342
+ - **`frame()` or the input is lost.** Events queue up until a `frame()` commits
343
+ them, and queued events that are never framed are dropped without an
344
+ exception or a log line. The one exception is a chain that ends in
345
+ `stop_emulating()`: libei frames what is queued, delivers it, and logs
346
+ `Bug: ei_device_stop_emulating: missing call to ei_device_frame()` at error
347
+ level. Measured against libei 1.2.1 and 1.6.0; don't rely on it.
362
348
  - **`bind()` needs at least one capability.** Binding an empty set sends
363
349
  nothing, so the device you are waiting for never arrives; this raises
364
350
  `ValueError` rather than hanging.
@@ -367,20 +353,22 @@ All four, with working code:
367
353
  - **One seat can resume several devices.** Bind both `POINTER` and
368
354
  `POINTER_ABSOLUTE` and GNOME gives you two, relative first. Taking
369
355
  whichever resumes first is a coin flip — select on `device.capabilities`
370
- instead. Sending an event the device lacks the capability for is silently
371
- ignored, which makes this look like the injection simply not working.
356
+ instead. Sending an event the device lacks the capability for is dropped with
357
+ no exception — libei only logs an error-level `Bug: ... device is not a
358
+ keyboard` line — which makes this look like the injection simply not working.
372
359
  - **Read the accessor that matches the event type.** `event.key_event` on a
373
360
  `POINTER_MOTION` event raises `TypeError` naming both types. libei itself
374
361
  would have returned `KeyEvent(key=0, is_press=False)` — a real-looking
375
- value — while logging a `Bug:` line the caller never sees, so branch on
376
- `event_type` first. `TOUCH_UP` has its own `touch_up_event`, since it
362
+ value — and raised nothing, only logging a `Bug:` line at error level, so
363
+ branch on `event_type` first. `TOUCH_UP` has its own `touch_up_event`, since it
377
364
  carries no coordinates.
378
365
  - **`GESTURES` and `STYLUS` are not in any released libei.** They match
379
366
  upstream `main` and are here ready for it, but 1.6.0's capability enum
380
367
  stops at `TEXT`. Binding them against a shipping library silently does
381
368
  nothing — no error, no device, no events.
382
369
 
383
- Nearly all of these fail *silently*, which is why
370
+ Nearly all of these fail without raising — some only log an error-level `Bug:`
371
+ line, some not even that — which is why
384
372
  [docs/troubleshooting.md](docs/troubleshooting.md) is a checklist rather than
385
373
  a list of error messages. Start there when nothing happens.
386
374
 
@@ -394,8 +382,10 @@ a list of error messages. Start there when nothing happens.
394
382
  happens" checklist
395
383
  - [docs/vs-snegg.md](docs/vs-snegg.md) — how this differs from the reference
396
384
  bindings, and two signature issues found by cross-checking the C source
397
- - [docs/developers/](docs/developers/) — the four-layer architecture, and what
398
- has actually been verified against which libei versions
385
+ - [docs/developers/architecture.md](docs/developers/architecture.md) and
386
+ [docs/developers/verification.md](docs/developers/verification.md) — the
387
+ four-layer architecture, and what has actually been verified against which
388
+ libei versions
399
389
  - [CONTRIBUTING.md](CONTRIBUTING.md) — setup, checks, testing against an old
400
390
  libei, releasing
401
391
 
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-libei"
7
- version = "0.5.2"
8
- description = "Inject and receive input on Wayland from Python: ctypes bindings for libei, libeis and liboeffis"
7
+ version = "0.6.1"
8
+ description = "Inject, receive and capture input on Wayland from Python: ctypes bindings for libei, libeis and liboeffis, plus the RemoteDesktop and InputCapture portals"
9
9
  readme = "README.md"
10
10
  license = "MIT"
11
11
  requires-python = ">=3.10"
@@ -18,6 +18,8 @@ keywords = [
18
18
  "input",
19
19
  "input-emulation",
20
20
  "emulated-input",
21
+ "input-capture",
22
+ "eis",
21
23
  "portal",
22
24
  "xdg-desktop-portal",
23
25
  "remote-desktop",
@@ -31,6 +33,7 @@ classifiers = [
31
33
  "Intended Audience :: Developers",
32
34
  "Operating System :: POSIX :: BSD :: FreeBSD",
33
35
  "Operating System :: POSIX :: Linux",
36
+ "Operating System :: POSIX",
34
37
  "Programming Language :: Python :: 3",
35
38
  "Programming Language :: Python :: 3 :: Only",
36
39
  "Programming Language :: Python :: 3.10",
@@ -62,6 +65,8 @@ dev = ["pytest>=7", "ruff>=0.6", "mypy>=1.11"]
62
65
  homepage = "https://github.com/ctrondlp/python-libei"
63
66
  repository = "https://github.com/ctrondlp/python-libei"
64
67
  issues = "https://github.com/ctrondlp/python-libei/issues"
68
+ documentation = "https://github.com/ctrondlp/python-libei/tree/main/docs"
69
+ pyguitest = "https://github.com/ctrondlp/pyguitest"
65
70
  changelog = "https://github.com/ctrondlp/python-libei/blob/main/CHANGELOG.md"
66
71
 
67
72
  [tool.setuptools.packages.find]
@@ -73,6 +78,14 @@ libei = ["py.typed"]
73
78
 
74
79
  [tool.pytest.ini_options]
75
80
  testpaths = ["tests"]
81
+ # The suite imports `libei` from the checkout rather than requiring an
82
+ # install, so `scripts/pre-commit-test.sh` runs on an interpreter with only
83
+ # the dev tools in it -- which is what that script's own header asks for, and
84
+ # what pyguitest and pyguitest-recorder already do. CI installs the package,
85
+ # so the installed path is still covered there. Without this, conftest's
86
+ # `from libei import ei` fails at collection and the whole check reports an
87
+ # ImportError instead of running anything.
88
+ pythonpath = ["src"]
76
89
  markers = [
77
90
  "integration: requires the real libei/libeis/liboeffis shared libraries to be installed",
78
91
  ]
@@ -31,6 +31,6 @@ the full breakdown, including which features need which libei version.
31
31
  Beta: the API is not frozen.
32
32
  """
33
33
 
34
- __version__ = "0.5.2"
34
+ __version__ = "0.6.1"
35
35
 
36
36
  __all__ = ["__version__"]
@@ -128,7 +128,7 @@ event_scroll_get_discrete_dy = lib.function(
128
128
  event_touch_get_id = lib.function("ei_event_touch_get_id", (c_void_p,), c_uint32)
129
129
  event_touch_get_x = lib.function("ei_event_touch_get_x", (c_void_p,), c_double)
130
130
  event_touch_get_y = lib.function("ei_event_touch_get_y", (c_void_p,), c_double)
131
- event_touch_get_is_cancel = lib.function(
131
+ event_touch_get_is_cancel = lib.function( # libei 1.4+
132
132
  "ei_event_touch_get_is_cancel", (c_void_p,), c_bool
133
133
  )
134
134
  event_text_get_utf8 = lib.function( # libei 1.6+
@@ -141,7 +141,9 @@ event_text_get_keysym_is_press = lib.function( # libei 1.6+
141
141
  "ei_event_text_get_keysym_is_press", (c_void_p,), c_bool
142
142
  )
143
143
  # Borrowed reference -- the event still owns it, so wrap() rather than adopt().
144
- event_pong_get_ping = lib.function("ei_event_pong_get_ping", (c_void_p,), c_void_p)
144
+ event_pong_get_ping = lib.function( # libei 1.4+
145
+ "ei_event_pong_get_ping", (c_void_p,), c_void_p
146
+ )
145
147
 
146
148
  device_ref = lib.function("ei_device_ref", (c_void_p,), c_void_p)
147
149
  device_unref = lib.function("ei_device_unref", (c_void_p,), c_void_p)
@@ -236,7 +238,7 @@ touch_get_device = lib.function("ei_touch_get_device", (c_void_p,), c_void_p)
236
238
  touch_down = lib.function("ei_touch_down", (c_void_p, c_double, c_double), None)
237
239
  touch_motion = lib.function("ei_touch_motion", (c_void_p, c_double, c_double), None)
238
240
  touch_up = lib.function("ei_touch_up", (c_void_p,), None)
239
- touch_cancel = lib.function("ei_touch_cancel", (c_void_p,), None)
241
+ touch_cancel = lib.function("ei_touch_cancel", (c_void_p,), None) # libei 1.4+
240
242
 
241
243
  # ei_new_ping() returns an owned reference; ei_ping() then triggers the round
242
244
  # trip that comes back as an EI_EVENT_PONG.
@@ -104,7 +104,7 @@ event_scroll_get_discrete_dy = lib.function(
104
104
  event_touch_get_id = lib.function("eis_event_touch_get_id", (c_void_p,), c_uint32)
105
105
  event_touch_get_x = lib.function("eis_event_touch_get_x", (c_void_p,), c_double)
106
106
  event_touch_get_y = lib.function("eis_event_touch_get_y", (c_void_p,), c_double)
107
- event_touch_get_is_cancel = lib.function(
107
+ event_touch_get_is_cancel = lib.function( # libei 1.4+
108
108
  "eis_event_touch_get_is_cancel", (c_void_p,), c_bool
109
109
  )
110
110
  event_text_get_utf8 = lib.function( # libei 1.6+
@@ -117,14 +117,16 @@ event_text_get_keysym_is_press = lib.function( # libei 1.6+
117
117
  "eis_event_text_get_keysym_is_press", (c_void_p,), c_bool
118
118
  )
119
119
  # Borrowed reference -- the event still owns it, so wrap() rather than adopt().
120
- event_pong_get_ping = lib.function("eis_event_pong_get_ping", (c_void_p,), c_void_p)
120
+ event_pong_get_ping = lib.function( # libei 1.4+
121
+ "eis_event_pong_get_ping", (c_void_p,), c_void_p
122
+ )
121
123
 
122
124
  client_ref = lib.function("eis_client_ref", (c_void_p,), c_void_p)
123
125
  client_unref = lib.function("eis_client_unref", (c_void_p,), c_void_p)
124
126
  client_is_sender = lib.function("eis_client_is_sender", (c_void_p,), c_bool)
125
127
  # pid_t, i.e. a 32-bit signed int on Linux. Socket backend only, and
126
128
  # negative errno on failure.
127
- backend_socket_get_client_pid = lib.function(
129
+ backend_socket_get_client_pid = lib.function( # libei 1.5+
128
130
  "eis_backend_socket_get_client_pid", (c_void_p,), c_int32
129
131
  )
130
132
  client_get_name = lib.function("eis_client_get_name", (c_void_p,), c_char_p)
@@ -277,7 +279,7 @@ touch_get_device = lib.function("eis_touch_get_device", (c_void_p,), c_void_p)
277
279
  touch_down = lib.function("eis_touch_down", (c_void_p, c_double, c_double), None)
278
280
  touch_motion = lib.function("eis_touch_motion", (c_void_p, c_double, c_double), None)
279
281
  touch_up = lib.function("eis_touch_up", (c_void_p,), None)
280
- touch_cancel = lib.function("eis_touch_cancel", (c_void_p,), None)
282
+ touch_cancel = lib.function("eis_touch_cancel", (c_void_p,), None) # libei 1.4+
281
283
 
282
284
  ping = lib.function("eis_ping", (c_void_p,), None) # libei 1.4+
283
285
  ping_get_id = lib.function("eis_ping_get_id", (c_void_p,), c_uint64) # libei 1.4+
@@ -25,12 +25,19 @@ class LazyLibrary:
25
25
  """A ctypes.CDLL that only opens the library on first real use."""
26
26
 
27
27
  def __init__(self, soname: str) -> None:
28
+ """Remember the soname; nothing is opened until first use."""
28
29
  self._soname = soname
29
30
  self._lib: ctypes.CDLL | None = None
30
31
  self._load_error: OSError | None = None
31
32
  self._lock = threading.Lock()
33
+ self._declared: dict[str, tuple[tuple[type, ...], type | None]] = {}
32
34
 
33
35
  def _ensure_loaded(self) -> ctypes.CDLL:
36
+ """Return the opened library, opening it on first call.
37
+
38
+ Raises LibraryNotFoundError on every later call too after a failure:
39
+ the failed load is cached rather than retried.
40
+ """
34
41
  # Double-checked: the unlocked read is the fast path taken by every
35
42
  # call after the first, and the repeated check inside the lock is
36
43
  # what makes it safe -- two threads can both fall through the first
@@ -69,6 +76,21 @@ class LazyLibrary:
69
76
  return False
70
77
  return True
71
78
 
79
+ @property
80
+ def soname(self) -> str:
81
+ """The shared library this binds, as it is passed to ``dlopen``."""
82
+ return self._soname
83
+
84
+ @property
85
+ def declared(self) -> dict[str, tuple[tuple[type, ...], type | None]]:
86
+ """Every function bound so far: C name -> (argtypes, restype).
87
+
88
+ Read by the ABI tests, which compare it with the library's exports and
89
+ with the upstream headers. Declaring a binding records it here and does
90
+ nothing else -- the library is still not opened until a call.
91
+ """
92
+ return dict(self._declared)
93
+
72
94
  def function(
73
95
  self,
74
96
  name: str,
@@ -88,9 +110,11 @@ class LazyLibrary:
88
110
  # the dict is chosen only because "absent from the dict" already
89
111
  # means "not resolved yet", with no None sentinel to confuse with a
90
112
  # legitimately-None value.
113
+ self._declared[name] = (tuple(argtypes), restype)
91
114
  cache: dict[str, Any] = {}
92
115
 
93
116
  def call(*args: Any) -> Any:
117
+ """Resolve the C function on first call, then pass straight through."""
94
118
  # Resolution happens here, on first call, not at bind time --
95
119
  # that is the whole point of this module (see its docstring).
96
120
  bound = cache.get("f")
@@ -106,7 +130,23 @@ class LazyLibrary:
106
130
  bound.argtypes = list(argtypes)
107
131
  bound.restype = restype
108
132
  cache["f"] = bound
109
- return bound(*args)
133
+ try:
134
+ return bound(*args)
135
+ except ctypes.ArgumentError:
136
+ # ctypes turns *any* exception raised while converting an
137
+ # argument into an ArgumentError carrying only its text -- no
138
+ # __cause__, no __context__. For a released object that loses
139
+ # the RuntimeError its ``_as_parameter_`` raised, so a caller
140
+ # catching RuntimeError (as documented) would miss it. Ask the
141
+ # arguments again and re-raise the real one.
142
+ for arg in args:
143
+ try:
144
+ arg._as_parameter_ # noqa: B018 - evaluated for its error
145
+ except RuntimeError as released:
146
+ raise released from None
147
+ except AttributeError:
148
+ continue
149
+ raise
110
150
 
111
151
  call.__name__ = name
112
152
  return call
@@ -66,6 +66,7 @@ class CObject:
66
66
  _instances_lock: ClassVar[threading.RLock]
67
67
 
68
68
  def __init_subclass__(cls, **kwargs: Any) -> None:
69
+ """Give each wrapper hierarchy one identity cache, shared by subclasses."""
69
70
  super().__init_subclass__(**kwargs)
70
71
  # One cache per wrapper *hierarchy*, not per class. Only a root
71
72
  # wrapper class -- one whose only CObject ancestor is CObject
@@ -98,6 +99,7 @@ class CObject:
98
99
  cls._instances_lock = threading.RLock()
99
100
 
100
101
  def __init__(self, pointer: int, *, _adopt: bool = False) -> None:
102
+ """Take a reference on a non-NULL pointer and cache this wrapper for it."""
101
103
  if not pointer:
102
104
  raise ValueError(f"{type(self).__name__} cannot wrap a NULL pointer")
103
105
  self._pointer = pointer
@@ -121,6 +123,7 @@ class CObject:
121
123
 
122
124
  @property
123
125
  def _as_parameter_(self) -> int:
126
+ """The raw pointer ctypes passes to C -- an error once it is released."""
124
127
  if self._pointer == 0:
125
128
  raise RuntimeError(
126
129
  f"{type(self).__name__} has already been released; "
@@ -159,6 +162,7 @@ class CObject:
159
162
 
160
163
  @classmethod
161
164
  def _get_or_create(cls: type[T], pointer: int | None, *, adopt: bool) -> T | None:
165
+ """The wrapper already caching this pointer, or a new one; None for NULL."""
162
166
  if not pointer:
163
167
  return None
164
168
  if not cls._wrappable:
@@ -232,6 +236,7 @@ class CObject:
232
236
  return cls._get_or_create(pointer, adopt=True)
233
237
 
234
238
  def __eq__(self, other: object) -> bool:
239
+ """Equal when the types match and the wrapped pointers are the same."""
235
240
  if not isinstance(other, CObject):
236
241
  return NotImplemented
237
242
  if type(self) is not type(other):
@@ -245,6 +250,7 @@ class CObject:
245
250
  return self._pointer == other._pointer
246
251
 
247
252
  def __hash__(self) -> int:
253
+ """Stable for the object's life: the original pointer, not the live one."""
248
254
  # Deliberately keyed on _hash_key, not _pointer: release() zeroes
249
255
  # _pointer, and an object whose hash changes mid-life vanishes
250
256
  # from any set or dict it was placed in. Two wrappers can share a