python-libei 0.1.0__tar.gz → 0.3.0__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.
- {python_libei-0.1.0/src/python_libei.egg-info → python_libei-0.3.0}/PKG-INFO +114 -37
- {python_libei-0.1.0 → python_libei-0.3.0}/README.md +110 -36
- {python_libei-0.1.0 → python_libei-0.3.0}/pyproject.toml +16 -1
- {python_libei-0.1.0 → python_libei-0.3.0}/src/libei/__init__.py +4 -1
- python_libei-0.3.0/src/libei/portal.py +708 -0
- {python_libei-0.1.0 → python_libei-0.3.0/src/python_libei.egg-info}/PKG-INFO +114 -37
- {python_libei-0.1.0 → python_libei-0.3.0}/src/python_libei.egg-info/SOURCES.txt +3 -1
- {python_libei-0.1.0 → python_libei-0.3.0}/src/python_libei.egg-info/requires.txt +3 -0
- {python_libei-0.1.0 → python_libei-0.3.0}/tests/test_documentation_shape.py +10 -3
- python_libei-0.3.0/tests/test_portal.py +632 -0
- {python_libei-0.1.0 → python_libei-0.3.0}/LICENSE +0 -0
- {python_libei-0.1.0 → python_libei-0.3.0}/setup.cfg +0 -0
- {python_libei-0.1.0 → python_libei-0.3.0}/src/libei/_capi/__init__.py +0 -0
- {python_libei-0.1.0 → python_libei-0.3.0}/src/libei/_capi/libei.py +0 -0
- {python_libei-0.1.0 → python_libei-0.3.0}/src/libei/_capi/libeis.py +0 -0
- {python_libei-0.1.0 → python_libei-0.3.0}/src/libei/_capi/liboeffis.py +0 -0
- {python_libei-0.1.0 → python_libei-0.3.0}/src/libei/_capi/loader.py +0 -0
- {python_libei-0.1.0 → python_libei-0.3.0}/src/libei/_cobject.py +0 -0
- {python_libei-0.1.0 → python_libei-0.3.0}/src/libei/ei.py +0 -0
- {python_libei-0.1.0 → python_libei-0.3.0}/src/libei/eis.py +0 -0
- {python_libei-0.1.0 → python_libei-0.3.0}/src/libei/oeffis.py +0 -0
- {python_libei-0.1.0 → python_libei-0.3.0}/src/libei/py.typed +0 -0
- {python_libei-0.1.0 → python_libei-0.3.0}/src/python_libei.egg-info/dependency_links.txt +0 -0
- {python_libei-0.1.0 → python_libei-0.3.0}/src/python_libei.egg-info/top_level.txt +0 -0
- {python_libei-0.1.0 → python_libei-0.3.0}/tests/test_cobject.py +0 -0
- {python_libei-0.1.0 → python_libei-0.3.0}/tests/test_documented_examples.py +0 -0
- {python_libei-0.1.0 → python_libei-0.3.0}/tests/test_ei_objects.py +0 -0
- {python_libei-0.1.0 → python_libei-0.3.0}/tests/test_eis_objects.py +0 -0
- {python_libei-0.1.0 → python_libei-0.3.0}/tests/test_integration_extras.py +0 -0
- {python_libei-0.1.0 → python_libei-0.3.0}/tests/test_integration_socketpair.py +0 -0
- {python_libei-0.1.0 → python_libei-0.3.0}/tests/test_loader.py +0 -0
- {python_libei-0.1.0 → python_libei-0.3.0}/tests/test_oeffis.py +0 -0
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-libei
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Inject and receive input on Wayland from Python: ctypes bindings for libei, libeis and liboeffis
|
|
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: changelog, https://github.com/ctrondlp/python-libei/blob/main/CHANGELOG.md
|
|
10
11
|
Keywords: wayland,libei,libeis,liboeffis,input,input-emulation,emulated-input,portal,xdg-desktop-portal,remote-desktop,automation,gui-testing,accessibility,ctypes
|
|
11
12
|
Classifier: Development Status :: 3 - Alpha
|
|
12
13
|
Classifier: Intended Audience :: Developers
|
|
@@ -25,6 +26,8 @@ Classifier: Typing :: Typed
|
|
|
25
26
|
Requires-Python: >=3.10
|
|
26
27
|
Description-Content-Type: text/markdown
|
|
27
28
|
License-File: LICENSE
|
|
29
|
+
Provides-Extra: portal
|
|
30
|
+
Requires-Dist: PyGObject>=3.42; extra == "portal"
|
|
28
31
|
Provides-Extra: dev
|
|
29
32
|
Requires-Dist: pytest>=7; extra == "dev"
|
|
30
33
|
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
@@ -133,8 +136,9 @@ pointer.
|
|
|
133
136
|
|
|
134
137
|
## Status
|
|
135
138
|
|
|
136
|
-
Alpha (`0.
|
|
137
|
-
renames before 1.0. What
|
|
139
|
+
Alpha (`0.3.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:
|
|
138
142
|
|
|
139
143
|
- The injection path — connect, bind, wait for a device, send events — is
|
|
140
144
|
exercised end-to-end against the real libraries by
|
|
@@ -143,9 +147,20 @@ renames before 1.0. What that qualifier covers, concretely:
|
|
|
143
147
|
- Text input, touch cancellation, ping/pong, keymap transfer, region mapping
|
|
144
148
|
ids and `peek_event_type()` are each round-tripped through a real libeis
|
|
145
149
|
server in `tests/test_integration_extras.py`.
|
|
146
|
-
-
|
|
147
|
-
|
|
148
|
-
|
|
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)
|
|
155
|
+
against a fake D-Bus connection only. `libei.oeffis` was verified by hand
|
|
156
|
+
on 2026-08-25 (see [Troubleshooting](#troubleshooting)), and
|
|
157
|
+
`libei.portal` on 2026-09-01 against a real GNOME Wayland session
|
|
158
|
+
(`RemoteDesktop` v2): a first run raised the consent dialog and was
|
|
159
|
+
approved (5.4s), a second replaying the `restore_token` was granted with
|
|
160
|
+
no dialog at all (0.2s), three devices resumed on the returned fd
|
|
161
|
+
(relative pointer, keyboard, absolute pointer — in that order, the device
|
|
162
|
+
race `ei`-side callers must handle), and `Session.Close()` was exercised.
|
|
163
|
+
No input was injected — emulation is `libei.ei`'s job.
|
|
149
164
|
- Verified against libei 1.6.0 on Fedora 44 / GNOME 50.4, and against a
|
|
150
165
|
locally built 1.2.1 (130 passed, 4 skipped — the 1.4 and 1.6 features
|
|
151
166
|
gate themselves out). CI repeats the 1.2.1 run on Python 3.10-3.13, so
|
|
@@ -157,7 +172,7 @@ renames before 1.0. What that qualifier covers, concretely:
|
|
|
157
172
|
| Instead of this | Why you might |
|
|
158
173
|
| --- | --- |
|
|
159
174
|
| [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 (`
|
|
175
|
+
| 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. |
|
|
161
176
|
| `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
177
|
|
|
163
178
|
## Requirements
|
|
@@ -165,6 +180,10 @@ renames before 1.0. What that qualifier covers, concretely:
|
|
|
165
180
|
- Linux with a Wayland compositor (GNOME, KDE, Sway, …)
|
|
166
181
|
- CPython 3.10 or newer (tested on 3.13)
|
|
167
182
|
- The native libraries: on Fedora, `sudo dnf install libei libeis liboeffis`
|
|
183
|
+
- `libei.portal` only: PyGObject (`pip install 'python-libei[portal]'`), plus
|
|
184
|
+
whatever GObject-introspection libraries your distro needs for `Gio` --
|
|
185
|
+
PyPI's PyGObject wheel supplies the Python side only. Not needed for
|
|
186
|
+
`libei.ei`, `libei.eis` or `libei.oeffis`.
|
|
168
187
|
- libei 1.0.0 or newer for the core: connecting, binding a seat, and
|
|
169
188
|
sending pointer, button, keyboard, scroll and touch input all use symbols
|
|
170
189
|
that have existed with a stable signature since 1.0.0, and upstream keeps
|
|
@@ -194,17 +213,29 @@ renames before 1.0. What that qualifier covers, concretely:
|
|
|
194
213
|
|
|
195
214
|
## Install
|
|
196
215
|
|
|
197
|
-
|
|
216
|
+
From [PyPI](https://pypi.org/project/python-libei/):
|
|
198
217
|
|
|
199
218
|
```sh
|
|
200
|
-
|
|
201
|
-
cd python-libei
|
|
202
|
-
pip install .
|
|
219
|
+
pip install python-libei
|
|
203
220
|
```
|
|
204
221
|
|
|
205
222
|
The distribution is named `python-libei`, the import is `libei` -- so
|
|
206
223
|
`pip show python-libei`, but `from libei import ei`.
|
|
207
224
|
|
|
225
|
+
Pure Python, no build step: the wheel is `py3-none-any` and ctypes talks to
|
|
226
|
+
the native libraries directly, so there is no compiler, no headers and no
|
|
227
|
+
`libei-devel` involved at install time. What `pip` does *not* bring is the
|
|
228
|
+
native libraries themselves -- see [Requirements](#requirements) above; on
|
|
229
|
+
Fedora, `sudo dnf install libei libeis liboeffis`.
|
|
230
|
+
|
|
231
|
+
To track `main` instead, or to hack on it, install from a checkout:
|
|
232
|
+
|
|
233
|
+
```sh
|
|
234
|
+
git clone https://github.com/ctrondlp/python-libei.git
|
|
235
|
+
cd python-libei
|
|
236
|
+
pip install . # or `pip install -e '.[dev]'` to develop
|
|
237
|
+
```
|
|
238
|
+
|
|
208
239
|
Importing is always safe, even where the native libraries are missing — they
|
|
209
240
|
are loaded on first use, not at import. Check before you rely on them:
|
|
210
241
|
|
|
@@ -288,23 +319,59 @@ several devices — see the absolute-positioning notes under
|
|
|
288
319
|
call and exposes no options dict, so the two things that make an approval
|
|
289
320
|
persist — `persist_mode` on `SelectDevices`, and the `restore_token` that
|
|
290
321
|
comes back on `Start` — are unreachable through it. This is a limitation of
|
|
291
|
-
the C library, not of these bindings;
|
|
322
|
+
the C library, not of these bindings; upstream's own docs say as much:
|
|
323
|
+
liboeffis is "intentionally kept simple, any more complex needs should be
|
|
324
|
+
handled by an application talking to DBus directly"
|
|
325
|
+
([source](https://libinput.pages.freedesktop.org/libei/api/group__liboeffis.html)).
|
|
292
326
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
327
|
+
`libei.portal` is that: the same `CreateSession` → `SelectDevices` → `Start`
|
|
328
|
+
→ `ConnectToEIS` sequence, driven directly over D-Bus (needs PyGObject —
|
|
329
|
+
`pip install 'python-libei[portal]'`), with `persist_mode`/`restore_token`
|
|
330
|
+
as real parameters:
|
|
296
331
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
332
|
+
```python
|
|
333
|
+
from libei import ei, portal
|
|
334
|
+
|
|
335
|
+
with portal.RemoteDesktopSession.negotiate(
|
|
336
|
+
devices=portal.DeviceType.POINTER,
|
|
337
|
+
persist_mode=portal.PersistMode.UNTIL_REVOKED,
|
|
338
|
+
restore_token=saved_token, # None on the first run
|
|
339
|
+
) as session:
|
|
340
|
+
save_somewhere(session.restore_token) # a fresh token every time -- save it
|
|
341
|
+
sender = ei.Sender.create_for_fd(session.eis_fd, name="my-app")
|
|
342
|
+
... # inject input for as long as the session is needed
|
|
343
|
+
```
|
|
302
344
|
|
|
303
345
|
Save the token somewhere durable and pass it back next time; the portal then
|
|
304
346
|
restores the session without prompting. Treat it as a credential — anyone
|
|
305
347
|
holding it can reopen input injection on that desktop, so it belongs
|
|
306
348
|
wherever you'd keep a password, and the decision to store it at all belongs
|
|
307
|
-
to the application rather than to
|
|
349
|
+
to the application rather than to this library, which never writes it
|
|
350
|
+
anywhere itself.
|
|
351
|
+
|
|
352
|
+
Save whatever comes back on **every** run, not just the first: the portal is
|
|
353
|
+
free to hand back a different token each time, and a caller that keeps only
|
|
354
|
+
the original would eventually present a stale one. (On GNOME the same token
|
|
355
|
+
comes back on each restore — that is one portal's behaviour, not a
|
|
356
|
+
guarantee.) Passing `restore_token` *without* a `persist_mode` raises
|
|
357
|
+
`ValueError`: the portal answers such a request with no token at all, so
|
|
358
|
+
storing what came back would write `None` over the token you just spent.
|
|
359
|
+
|
|
360
|
+
Three differences from `Oeffis` above worth knowing:
|
|
361
|
+
|
|
362
|
+
- **Blocking, not event-driven.** `negotiate()` runs its own nested
|
|
363
|
+
`GLib.MainLoop` per D-Bus round trip and returns only once connected, or
|
|
364
|
+
raises `PortalVersionError` / `PortalDeniedError` / `PortalTimeoutError`.
|
|
365
|
+
- **Bounded.** Each round trip gets `timeout` seconds (60 by default —
|
|
366
|
+
generous, since `Start` waits on a human answering a dialog). Without it a
|
|
367
|
+
portal that dies after accepting the call would wedge the calling thread
|
|
368
|
+
forever, which is the one thing `Oeffis`'s pollable fd protects against.
|
|
369
|
+
- **Close it.** The portal session lives in xdg-desktop-portal and outlives
|
|
370
|
+
the object unless `Session.Close()` is called — `Gio.bus_get_sync()` hands
|
|
371
|
+
back GLib's *shared* connection, so dropping the session tears nothing
|
|
372
|
+
down, and a long-running process that negotiates repeatedly accumulates
|
|
373
|
+
live sessions. The `with` block above handles it; otherwise call
|
|
374
|
+
`session.close()`.
|
|
308
375
|
|
|
309
376
|
## Sending input
|
|
310
377
|
|
|
@@ -633,6 +700,7 @@ the capability you bound (`seat.capabilities`).
|
|
|
633
700
|
| `libei.ei` | Clients: `Sender` (inject), `Receiver` (consume) |
|
|
634
701
|
| `libei.eis` | Servers: `Eis`, for compositors and for testing clients |
|
|
635
702
|
| `libei.oeffis` | Getting an EI fd from the desktop portal |
|
|
703
|
+
| `libei.portal` | The same, over D-Bus directly, with `persist_mode`/`restore_token` |
|
|
636
704
|
|
|
637
705
|
Each module has `is_available()`, an `Error` exception, and an `EventType` /
|
|
638
706
|
`DeviceCapability` enum. `ei` and `eis` also share the shapes around them:
|
|
@@ -657,6 +725,12 @@ them in this order -- each one only makes sense once the one below it does.
|
|
|
657
725
|
| [`_cobject.py`](src/libei/_cobject.py) | `CObject`: pointer ownership, refcounting, and the identity cache that every wrapper class inherits |
|
|
658
726
|
| [`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
727
|
|
|
728
|
+
[`portal.py`](src/libei/portal.py) sits outside this stack entirely -- there
|
|
729
|
+
is no C library behind it, so no `_capi` binding and no `CObject`. It talks
|
|
730
|
+
D-Bus directly through PyGObject (`Gio`/`GLib`, imported lazily the same way
|
|
731
|
+
the C libraries are loaded lazily) and only ever produces a plain fd, which
|
|
732
|
+
is where it hands off to `ei.Sender.create_for_fd()`.
|
|
733
|
+
|
|
660
734
|
**Read `_cobject.py` first.** It is the smallest file with the most
|
|
661
735
|
consequence: get `wrap()` vs `adopt()`, the `staticmethod()` wrapping of
|
|
662
736
|
`_ref_func`/`_unref_func`, or the `_wrappable` flag wrong and the failure is
|
|
@@ -721,16 +795,17 @@ Versions are SemVer and live in two places -- `pyproject.toml` and
|
|
|
721
795
|
`src/libei/__init__.py` -- which have to agree with each other and with the
|
|
722
796
|
tag. Nothing enforces that yet.
|
|
723
797
|
|
|
724
|
-
A release is an annotated, `v`-prefixed tag
|
|
798
|
+
A release is an annotated, `v`-prefixed tag. Pushing it is the whole of it;
|
|
799
|
+
PyPI is the only place a release is published, and no GitHub Release is cut:
|
|
725
800
|
|
|
726
801
|
```sh
|
|
727
|
-
git tag -a v0.
|
|
728
|
-
git push origin v0.
|
|
729
|
-
gh release create v0.1.0 --generate-notes --prerelease
|
|
802
|
+
git tag -a v0.2.0 -m "0.2.0"
|
|
803
|
+
git push origin v0.2.0
|
|
730
804
|
```
|
|
731
805
|
|
|
732
|
-
|
|
733
|
-
|
|
806
|
+
While the API is unfrozen, the pre-release signal lives in the version
|
|
807
|
+
itself: a PEP 440 suffix (`0.2.0a1`) keeps a plain `pip install
|
|
808
|
+
python-libei` off it, and a `0.x` version already says the API can move.
|
|
734
809
|
|
|
735
810
|
Publishing runs from CI on a `v*` tag using PyPI
|
|
736
811
|
[Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (OIDC), so
|
|
@@ -739,22 +814,24 @@ job in `ci.yml` handles it, uploading the artifacts the `build` job already
|
|
|
739
814
|
ran `twine check` over.
|
|
740
815
|
|
|
741
816
|
That job depends on two pieces of configuration outside this repository,
|
|
742
|
-
which
|
|
743
|
-
|
|
744
|
-
1. On pypi.org,
|
|
745
|
-
|
|
746
|
-
`
|
|
747
|
-
|
|
817
|
+
both of which are in place as of `0.1.0`:
|
|
818
|
+
|
|
819
|
+
1. On pypi.org, a trusted publisher on the `python-libei` project: owner
|
|
820
|
+
`ctrondlp`, repository `python-libei`, workflow `ci.yml`, environment
|
|
821
|
+
`pypi`. It started life as a **pending** publisher -- the flow for a
|
|
822
|
+
project with no releases yet -- and the first upload converted it into
|
|
823
|
+
an ordinary project-level one, so a fresh project is the only case that
|
|
824
|
+
needs the pending form again. Every field has to match the workflow
|
|
748
825
|
exactly; a mismatch surfaces as a rejected credential at upload time,
|
|
749
826
|
not when it is saved.
|
|
750
827
|
2. A GitHub environment named `pypi`, in the repository settings. A
|
|
751
828
|
required reviewer on it makes each publish a deliberate approval rather
|
|
752
829
|
than a side effect of pushing a tag.
|
|
753
830
|
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
831
|
+
PyPI filenames are immutable, so a bad upload can only be yanked and
|
|
832
|
+
superseded by a new version, never replaced -- worth rehearsing anything
|
|
833
|
+
unusual on TestPyPI first (separate account, separate pending publisher,
|
|
834
|
+
and `repository-url: https://test.pypi.org/legacy/` on the publish step).
|
|
758
835
|
|
|
759
836
|
## Design notes
|
|
760
837
|
|
|
@@ -100,8 +100,9 @@ pointer.
|
|
|
100
100
|
|
|
101
101
|
## Status
|
|
102
102
|
|
|
103
|
-
Alpha (`0.
|
|
104
|
-
renames before 1.0. What
|
|
103
|
+
Alpha (`0.3.0`), published on [PyPI](https://pypi.org/project/python-libei/)
|
|
104
|
+
since `0.1.0`, and the API is not frozen — expect renames before 1.0. What
|
|
105
|
+
that qualifier covers, concretely:
|
|
105
106
|
|
|
106
107
|
- The injection path — connect, bind, wait for a device, send events — is
|
|
107
108
|
exercised end-to-end against the real libraries by
|
|
@@ -110,9 +111,20 @@ renames before 1.0. What that qualifier covers, concretely:
|
|
|
110
111
|
- Text input, touch cancellation, ping/pong, keymap transfer, region mapping
|
|
111
112
|
ids and `peek_event_type()` are each round-tripped through a real libeis
|
|
112
113
|
server in `tests/test_integration_extras.py`.
|
|
113
|
-
-
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
- Both portal paths (`libei.oeffis` and `libei.portal`) can only ever be
|
|
115
|
+
verified by hand, since they need an interactive consent dialog that
|
|
116
|
+
nothing here can drive automatically — `tests/test_portal.py` covers
|
|
117
|
+
`libei.portal`'s orchestration (raceless subscribe-before-call, the
|
|
118
|
+
`session_handle_token` crash workaround, persist_mode/restore_token)
|
|
119
|
+
against a fake D-Bus connection only. `libei.oeffis` was verified by hand
|
|
120
|
+
on 2026-08-25 (see [Troubleshooting](#troubleshooting)), and
|
|
121
|
+
`libei.portal` on 2026-09-01 against a real GNOME Wayland session
|
|
122
|
+
(`RemoteDesktop` v2): a first run raised the consent dialog and was
|
|
123
|
+
approved (5.4s), a second replaying the `restore_token` was granted with
|
|
124
|
+
no dialog at all (0.2s), three devices resumed on the returned fd
|
|
125
|
+
(relative pointer, keyboard, absolute pointer — in that order, the device
|
|
126
|
+
race `ei`-side callers must handle), and `Session.Close()` was exercised.
|
|
127
|
+
No input was injected — emulation is `libei.ei`'s job.
|
|
116
128
|
- Verified against libei 1.6.0 on Fedora 44 / GNOME 50.4, and against a
|
|
117
129
|
locally built 1.2.1 (130 passed, 4 skipped — the 1.4 and 1.6 features
|
|
118
130
|
gate themselves out). CI repeats the 1.2.1 run on Python 3.10-3.13, so
|
|
@@ -124,7 +136,7 @@ renames before 1.0. What that qualifier covers, concretely:
|
|
|
124
136
|
| Instead of this | Why you might |
|
|
125
137
|
| --- | --- |
|
|
126
138
|
| [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. |
|
|
127
|
-
| The portal's D-Bus API directly (`
|
|
139
|
+
| 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. |
|
|
128
140
|
| `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. |
|
|
129
141
|
|
|
130
142
|
## Requirements
|
|
@@ -132,6 +144,10 @@ renames before 1.0. What that qualifier covers, concretely:
|
|
|
132
144
|
- Linux with a Wayland compositor (GNOME, KDE, Sway, …)
|
|
133
145
|
- CPython 3.10 or newer (tested on 3.13)
|
|
134
146
|
- The native libraries: on Fedora, `sudo dnf install libei libeis liboeffis`
|
|
147
|
+
- `libei.portal` only: PyGObject (`pip install 'python-libei[portal]'`), plus
|
|
148
|
+
whatever GObject-introspection libraries your distro needs for `Gio` --
|
|
149
|
+
PyPI's PyGObject wheel supplies the Python side only. Not needed for
|
|
150
|
+
`libei.ei`, `libei.eis` or `libei.oeffis`.
|
|
135
151
|
- libei 1.0.0 or newer for the core: connecting, binding a seat, and
|
|
136
152
|
sending pointer, button, keyboard, scroll and touch input all use symbols
|
|
137
153
|
that have existed with a stable signature since 1.0.0, and upstream keeps
|
|
@@ -161,17 +177,29 @@ renames before 1.0. What that qualifier covers, concretely:
|
|
|
161
177
|
|
|
162
178
|
## Install
|
|
163
179
|
|
|
164
|
-
|
|
180
|
+
From [PyPI](https://pypi.org/project/python-libei/):
|
|
165
181
|
|
|
166
182
|
```sh
|
|
167
|
-
|
|
168
|
-
cd python-libei
|
|
169
|
-
pip install .
|
|
183
|
+
pip install python-libei
|
|
170
184
|
```
|
|
171
185
|
|
|
172
186
|
The distribution is named `python-libei`, the import is `libei` -- so
|
|
173
187
|
`pip show python-libei`, but `from libei import ei`.
|
|
174
188
|
|
|
189
|
+
Pure Python, no build step: the wheel is `py3-none-any` and ctypes talks to
|
|
190
|
+
the native libraries directly, so there is no compiler, no headers and no
|
|
191
|
+
`libei-devel` involved at install time. What `pip` does *not* bring is the
|
|
192
|
+
native libraries themselves -- see [Requirements](#requirements) above; on
|
|
193
|
+
Fedora, `sudo dnf install libei libeis liboeffis`.
|
|
194
|
+
|
|
195
|
+
To track `main` instead, or to hack on it, install from a checkout:
|
|
196
|
+
|
|
197
|
+
```sh
|
|
198
|
+
git clone https://github.com/ctrondlp/python-libei.git
|
|
199
|
+
cd python-libei
|
|
200
|
+
pip install . # or `pip install -e '.[dev]'` to develop
|
|
201
|
+
```
|
|
202
|
+
|
|
175
203
|
Importing is always safe, even where the native libraries are missing — they
|
|
176
204
|
are loaded on first use, not at import. Check before you rely on them:
|
|
177
205
|
|
|
@@ -255,23 +283,59 @@ several devices — see the absolute-positioning notes under
|
|
|
255
283
|
call and exposes no options dict, so the two things that make an approval
|
|
256
284
|
persist — `persist_mode` on `SelectDevices`, and the `restore_token` that
|
|
257
285
|
comes back on `Start` — are unreachable through it. This is a limitation of
|
|
258
|
-
the C library, not of these bindings;
|
|
286
|
+
the C library, not of these bindings; upstream's own docs say as much:
|
|
287
|
+
liboeffis is "intentionally kept simple, any more complex needs should be
|
|
288
|
+
handled by an application talking to DBus directly"
|
|
289
|
+
([source](https://libinput.pages.freedesktop.org/libei/api/group__liboeffis.html)).
|
|
259
290
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
291
|
+
`libei.portal` is that: the same `CreateSession` → `SelectDevices` → `Start`
|
|
292
|
+
→ `ConnectToEIS` sequence, driven directly over D-Bus (needs PyGObject —
|
|
293
|
+
`pip install 'python-libei[portal]'`), with `persist_mode`/`restore_token`
|
|
294
|
+
as real parameters:
|
|
263
295
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
296
|
+
```python
|
|
297
|
+
from libei import ei, portal
|
|
298
|
+
|
|
299
|
+
with portal.RemoteDesktopSession.negotiate(
|
|
300
|
+
devices=portal.DeviceType.POINTER,
|
|
301
|
+
persist_mode=portal.PersistMode.UNTIL_REVOKED,
|
|
302
|
+
restore_token=saved_token, # None on the first run
|
|
303
|
+
) as session:
|
|
304
|
+
save_somewhere(session.restore_token) # a fresh token every time -- save it
|
|
305
|
+
sender = ei.Sender.create_for_fd(session.eis_fd, name="my-app")
|
|
306
|
+
... # inject input for as long as the session is needed
|
|
307
|
+
```
|
|
269
308
|
|
|
270
309
|
Save the token somewhere durable and pass it back next time; the portal then
|
|
271
310
|
restores the session without prompting. Treat it as a credential — anyone
|
|
272
311
|
holding it can reopen input injection on that desktop, so it belongs
|
|
273
312
|
wherever you'd keep a password, and the decision to store it at all belongs
|
|
274
|
-
to the application rather than to
|
|
313
|
+
to the application rather than to this library, which never writes it
|
|
314
|
+
anywhere itself.
|
|
315
|
+
|
|
316
|
+
Save whatever comes back on **every** run, not just the first: the portal is
|
|
317
|
+
free to hand back a different token each time, and a caller that keeps only
|
|
318
|
+
the original would eventually present a stale one. (On GNOME the same token
|
|
319
|
+
comes back on each restore — that is one portal's behaviour, not a
|
|
320
|
+
guarantee.) Passing `restore_token` *without* a `persist_mode` raises
|
|
321
|
+
`ValueError`: the portal answers such a request with no token at all, so
|
|
322
|
+
storing what came back would write `None` over the token you just spent.
|
|
323
|
+
|
|
324
|
+
Three differences from `Oeffis` above worth knowing:
|
|
325
|
+
|
|
326
|
+
- **Blocking, not event-driven.** `negotiate()` runs its own nested
|
|
327
|
+
`GLib.MainLoop` per D-Bus round trip and returns only once connected, or
|
|
328
|
+
raises `PortalVersionError` / `PortalDeniedError` / `PortalTimeoutError`.
|
|
329
|
+
- **Bounded.** Each round trip gets `timeout` seconds (60 by default —
|
|
330
|
+
generous, since `Start` waits on a human answering a dialog). Without it a
|
|
331
|
+
portal that dies after accepting the call would wedge the calling thread
|
|
332
|
+
forever, which is the one thing `Oeffis`'s pollable fd protects against.
|
|
333
|
+
- **Close it.** The portal session lives in xdg-desktop-portal and outlives
|
|
334
|
+
the object unless `Session.Close()` is called — `Gio.bus_get_sync()` hands
|
|
335
|
+
back GLib's *shared* connection, so dropping the session tears nothing
|
|
336
|
+
down, and a long-running process that negotiates repeatedly accumulates
|
|
337
|
+
live sessions. The `with` block above handles it; otherwise call
|
|
338
|
+
`session.close()`.
|
|
275
339
|
|
|
276
340
|
## Sending input
|
|
277
341
|
|
|
@@ -600,6 +664,7 @@ the capability you bound (`seat.capabilities`).
|
|
|
600
664
|
| `libei.ei` | Clients: `Sender` (inject), `Receiver` (consume) |
|
|
601
665
|
| `libei.eis` | Servers: `Eis`, for compositors and for testing clients |
|
|
602
666
|
| `libei.oeffis` | Getting an EI fd from the desktop portal |
|
|
667
|
+
| `libei.portal` | The same, over D-Bus directly, with `persist_mode`/`restore_token` |
|
|
603
668
|
|
|
604
669
|
Each module has `is_available()`, an `Error` exception, and an `EventType` /
|
|
605
670
|
`DeviceCapability` enum. `ei` and `eis` also share the shapes around them:
|
|
@@ -624,6 +689,12 @@ them in this order -- each one only makes sense once the one below it does.
|
|
|
624
689
|
| [`_cobject.py`](src/libei/_cobject.py) | `CObject`: pointer ownership, refcounting, and the identity cache that every wrapper class inherits |
|
|
625
690
|
| [`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 |
|
|
626
691
|
|
|
692
|
+
[`portal.py`](src/libei/portal.py) sits outside this stack entirely -- there
|
|
693
|
+
is no C library behind it, so no `_capi` binding and no `CObject`. It talks
|
|
694
|
+
D-Bus directly through PyGObject (`Gio`/`GLib`, imported lazily the same way
|
|
695
|
+
the C libraries are loaded lazily) and only ever produces a plain fd, which
|
|
696
|
+
is where it hands off to `ei.Sender.create_for_fd()`.
|
|
697
|
+
|
|
627
698
|
**Read `_cobject.py` first.** It is the smallest file with the most
|
|
628
699
|
consequence: get `wrap()` vs `adopt()`, the `staticmethod()` wrapping of
|
|
629
700
|
`_ref_func`/`_unref_func`, or the `_wrappable` flag wrong and the failure is
|
|
@@ -688,16 +759,17 @@ Versions are SemVer and live in two places -- `pyproject.toml` and
|
|
|
688
759
|
`src/libei/__init__.py` -- which have to agree with each other and with the
|
|
689
760
|
tag. Nothing enforces that yet.
|
|
690
761
|
|
|
691
|
-
A release is an annotated, `v`-prefixed tag
|
|
762
|
+
A release is an annotated, `v`-prefixed tag. Pushing it is the whole of it;
|
|
763
|
+
PyPI is the only place a release is published, and no GitHub Release is cut:
|
|
692
764
|
|
|
693
765
|
```sh
|
|
694
|
-
git tag -a v0.
|
|
695
|
-
git push origin v0.
|
|
696
|
-
gh release create v0.1.0 --generate-notes --prerelease
|
|
766
|
+
git tag -a v0.2.0 -m "0.2.0"
|
|
767
|
+
git push origin v0.2.0
|
|
697
768
|
```
|
|
698
769
|
|
|
699
|
-
|
|
700
|
-
|
|
770
|
+
While the API is unfrozen, the pre-release signal lives in the version
|
|
771
|
+
itself: a PEP 440 suffix (`0.2.0a1`) keeps a plain `pip install
|
|
772
|
+
python-libei` off it, and a `0.x` version already says the API can move.
|
|
701
773
|
|
|
702
774
|
Publishing runs from CI on a `v*` tag using PyPI
|
|
703
775
|
[Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (OIDC), so
|
|
@@ -706,22 +778,24 @@ job in `ci.yml` handles it, uploading the artifacts the `build` job already
|
|
|
706
778
|
ran `twine check` over.
|
|
707
779
|
|
|
708
780
|
That job depends on two pieces of configuration outside this repository,
|
|
709
|
-
which
|
|
710
|
-
|
|
711
|
-
1. On pypi.org,
|
|
712
|
-
|
|
713
|
-
`
|
|
714
|
-
|
|
781
|
+
both of which are in place as of `0.1.0`:
|
|
782
|
+
|
|
783
|
+
1. On pypi.org, a trusted publisher on the `python-libei` project: owner
|
|
784
|
+
`ctrondlp`, repository `python-libei`, workflow `ci.yml`, environment
|
|
785
|
+
`pypi`. It started life as a **pending** publisher -- the flow for a
|
|
786
|
+
project with no releases yet -- and the first upload converted it into
|
|
787
|
+
an ordinary project-level one, so a fresh project is the only case that
|
|
788
|
+
needs the pending form again. Every field has to match the workflow
|
|
715
789
|
exactly; a mismatch surfaces as a rejected credential at upload time,
|
|
716
790
|
not when it is saved.
|
|
717
791
|
2. A GitHub environment named `pypi`, in the repository settings. A
|
|
718
792
|
required reviewer on it makes each publish a deliberate approval rather
|
|
719
793
|
than a side effect of pushing a tag.
|
|
720
794
|
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
795
|
+
PyPI filenames are immutable, so a bad upload can only be yanked and
|
|
796
|
+
superseded by a new version, never replaced -- worth rehearsing anything
|
|
797
|
+
unusual on TestPyPI first (separate account, separate pending publisher,
|
|
798
|
+
and `repository-url: https://test.pypi.org/legacy/` on the publish step).
|
|
725
799
|
|
|
726
800
|
## Design notes
|
|
727
801
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-libei"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Inject and receive input on Wayland from Python: ctypes bindings for libei, libeis and liboeffis"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -50,12 +50,18 @@ classifiers = [
|
|
|
50
50
|
dependencies = []
|
|
51
51
|
|
|
52
52
|
[project.optional-dependencies]
|
|
53
|
+
# libei.portal negotiates the RemoteDesktop portal directly over D-Bus, which
|
|
54
|
+
# needs PyGObject for Gio/GLib. The native GObject-introspection libraries
|
|
55
|
+
# still have to come from the distro -- PyPI's PyGObject wheel only supplies
|
|
56
|
+
# the Python side. Not needed for libei.ei/libei.eis/libei.oeffis.
|
|
57
|
+
portal = ["PyGObject>=3.42"]
|
|
53
58
|
dev = ["pytest>=7", "ruff>=0.6", "mypy>=1.11"]
|
|
54
59
|
|
|
55
60
|
[project.urls]
|
|
56
61
|
homepage = "https://github.com/ctrondlp/python-libei"
|
|
57
62
|
repository = "https://github.com/ctrondlp/python-libei"
|
|
58
63
|
issues = "https://github.com/ctrondlp/python-libei/issues"
|
|
64
|
+
changelog = "https://github.com/ctrondlp/python-libei/blob/main/CHANGELOG.md"
|
|
59
65
|
|
|
60
66
|
[tool.setuptools.packages.find]
|
|
61
67
|
where = ["src"]
|
|
@@ -95,3 +101,12 @@ strict = true
|
|
|
95
101
|
# Every property in ei.py/eis.py/oeffis.py re-types a raw C call's result,
|
|
96
102
|
# so this fires everywhere in the wrapper layer for no actionable reason.
|
|
97
103
|
warn_return_any = false
|
|
104
|
+
|
|
105
|
+
# PyGObject is an optional extra (see [project.optional-dependencies].portal),
|
|
106
|
+
# so `gi` may or may not be importable where mypy runs. Without this, the two
|
|
107
|
+
# error codes differ by environment -- import-untyped when PyGObject is
|
|
108
|
+
# installed, import-not-found when it isn't -- and an inline ignore pinned to
|
|
109
|
+
# either one fails in the other. libei.portal already treats gi as Any.
|
|
110
|
+
[[tool.mypy.overrides]]
|
|
111
|
+
module = ["gi.*"]
|
|
112
|
+
ignore_missing_imports = true
|
|
@@ -13,6 +13,9 @@ Use the submodules directly:
|
|
|
13
13
|
drive a test harness for the ``ei`` module without a real compositor)
|
|
14
14
|
- :mod:`libei.oeffis` -- negotiate an EI connection through the
|
|
15
15
|
``org.freedesktop.portal.RemoteDesktop`` XDG desktop portal
|
|
16
|
+
- :mod:`libei.portal` -- negotiate that same portal directly over D-Bus
|
|
17
|
+
instead, for ``persist_mode``/``restore_token`` support liboeffis's C API
|
|
18
|
+
doesn't expose
|
|
16
19
|
|
|
17
20
|
Scope, in short: this needs a compositor speaking EI/EIS -- there is no X11
|
|
18
21
|
fallback. Pointer (relative and absolute), button, keyboard, scroll, touch
|
|
@@ -26,6 +29,6 @@ the full breakdown, including which features need which libei version.
|
|
|
26
29
|
Alpha: the API is not frozen.
|
|
27
30
|
"""
|
|
28
31
|
|
|
29
|
-
__version__ = "0.
|
|
32
|
+
__version__ = "0.3.0"
|
|
30
33
|
|
|
31
34
|
__all__ = ["__version__"]
|