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.
- libei/__init__.py +31 -0
- libei/_capi/__init__.py +6 -0
- libei/_capi/libei.py +247 -0
- libei/_capi/libeis.py +285 -0
- libei/_capi/liboeffis.py +29 -0
- libei/_capi/loader.py +112 -0
- libei/_cobject.py +252 -0
- libei/ei.py +1244 -0
- libei/eis.py +1234 -0
- libei/oeffis.py +238 -0
- libei/py.typed +0 -0
- python_libei-0.1.0.dist-info/METADATA +770 -0
- python_libei-0.1.0.dist-info/RECORD +16 -0
- python_libei-0.1.0.dist-info/WHEEL +5 -0
- python_libei-0.1.0.dist-info/licenses/LICENSE +21 -0
- python_libei-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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.
|