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.
Files changed (32) hide show
  1. {python_libei-0.1.0/src/python_libei.egg-info → python_libei-0.3.0}/PKG-INFO +114 -37
  2. {python_libei-0.1.0 → python_libei-0.3.0}/README.md +110 -36
  3. {python_libei-0.1.0 → python_libei-0.3.0}/pyproject.toml +16 -1
  4. {python_libei-0.1.0 → python_libei-0.3.0}/src/libei/__init__.py +4 -1
  5. python_libei-0.3.0/src/libei/portal.py +708 -0
  6. {python_libei-0.1.0 → python_libei-0.3.0/src/python_libei.egg-info}/PKG-INFO +114 -37
  7. {python_libei-0.1.0 → python_libei-0.3.0}/src/python_libei.egg-info/SOURCES.txt +3 -1
  8. {python_libei-0.1.0 → python_libei-0.3.0}/src/python_libei.egg-info/requires.txt +3 -0
  9. {python_libei-0.1.0 → python_libei-0.3.0}/tests/test_documentation_shape.py +10 -3
  10. python_libei-0.3.0/tests/test_portal.py +632 -0
  11. {python_libei-0.1.0 → python_libei-0.3.0}/LICENSE +0 -0
  12. {python_libei-0.1.0 → python_libei-0.3.0}/setup.cfg +0 -0
  13. {python_libei-0.1.0 → python_libei-0.3.0}/src/libei/_capi/__init__.py +0 -0
  14. {python_libei-0.1.0 → python_libei-0.3.0}/src/libei/_capi/libei.py +0 -0
  15. {python_libei-0.1.0 → python_libei-0.3.0}/src/libei/_capi/libeis.py +0 -0
  16. {python_libei-0.1.0 → python_libei-0.3.0}/src/libei/_capi/liboeffis.py +0 -0
  17. {python_libei-0.1.0 → python_libei-0.3.0}/src/libei/_capi/loader.py +0 -0
  18. {python_libei-0.1.0 → python_libei-0.3.0}/src/libei/_cobject.py +0 -0
  19. {python_libei-0.1.0 → python_libei-0.3.0}/src/libei/ei.py +0 -0
  20. {python_libei-0.1.0 → python_libei-0.3.0}/src/libei/eis.py +0 -0
  21. {python_libei-0.1.0 → python_libei-0.3.0}/src/libei/oeffis.py +0 -0
  22. {python_libei-0.1.0 → python_libei-0.3.0}/src/libei/py.typed +0 -0
  23. {python_libei-0.1.0 → python_libei-0.3.0}/src/python_libei.egg-info/dependency_links.txt +0 -0
  24. {python_libei-0.1.0 → python_libei-0.3.0}/src/python_libei.egg-info/top_level.txt +0 -0
  25. {python_libei-0.1.0 → python_libei-0.3.0}/tests/test_cobject.py +0 -0
  26. {python_libei-0.1.0 → python_libei-0.3.0}/tests/test_documented_examples.py +0 -0
  27. {python_libei-0.1.0 → python_libei-0.3.0}/tests/test_ei_objects.py +0 -0
  28. {python_libei-0.1.0 → python_libei-0.3.0}/tests/test_eis_objects.py +0 -0
  29. {python_libei-0.1.0 → python_libei-0.3.0}/tests/test_integration_extras.py +0 -0
  30. {python_libei-0.1.0 → python_libei-0.3.0}/tests/test_integration_socketpair.py +0 -0
  31. {python_libei-0.1.0 → python_libei-0.3.0}/tests/test_loader.py +0 -0
  32. {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.1.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.1.0`), not yet on PyPI, and the API is not frozen — expect
137
- renames before 1.0. What that qualifier covers, concretely:
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
- - 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).
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 (`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. |
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
- Not published to PyPI yet — install from a checkout:
216
+ From [PyPI](https://pypi.org/project/python-libei/):
198
217
 
199
218
  ```sh
200
- git clone https://github.com/ctrondlp/python-libei.git
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; there is nothing here left to bind.
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
- 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:
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
- 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()`
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 a library.
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 plus a GitHub Release:
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.1.0 -m "0.1.0"
728
- git push origin v0.1.0
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
- `--prerelease` while the API is unfrozen -- it keeps an alpha out of the
733
- "Latest release" slot.
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 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
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
- 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.
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.1.0`), not yet on PyPI, and the API is not frozen — expect
104
- renames before 1.0. What that qualifier covers, concretely:
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
- - The portal path (`libei.oeffis`) has only ever been verified by hand, since
114
- it needs an interactive consent dialog that nothing here can drive. See
115
- [Troubleshooting](#troubleshooting).
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 (`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. |
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
- Not published to PyPI yet — install from a checkout:
180
+ From [PyPI](https://pypi.org/project/python-libei/):
165
181
 
166
182
  ```sh
167
- git clone https://github.com/ctrondlp/python-libei.git
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; there is nothing here left to bind.
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
- What works is negotiating the portal yourself over D-Bus and handing the
261
- resulting fd to `Sender.create_for_fd()`, which does not care where the fd
262
- came from:
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
- 1. `CreateSession` on `org.freedesktop.portal.RemoteDesktop`
265
- 2. `SelectDevices` with `persist_mode` (1 = while running, 2 = until
266
- revoked) and, on later runs, the saved `restore_token`
267
- 3. `Start` — the response carries a fresh `restore_token`, which you store
268
- 4. `ConnectToEIS` — the fd for `ei.Sender.create_for_fd()`
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 a library.
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 plus a GitHub Release:
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.1.0 -m "0.1.0"
695
- git push origin v0.1.0
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
- `--prerelease` while the API is unfrozen -- it keeps an alpha out of the
700
- "Latest release" slot.
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 have to exist before the first tag:
710
-
711
- 1. On pypi.org, under Account settings -> Publishing, a **pending**
712
- publisher -- the flow for a project that has no releases yet. Project
713
- `python-libei`, owner `ctrondlp`, repository `python-libei`, workflow
714
- `ci.yml`, environment `pypi`. Every field has to match the workflow
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
- Worth rehearsing on TestPyPI first: separate account, separate pending
722
- publisher, and `repository-url: https://test.pypi.org/legacy/` on the
723
- publish step. PyPI filenames are immutable, so a bad upload can only be
724
- yanked and superseded by a new version, never replaced.
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.1.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.1.0"
32
+ __version__ = "0.3.0"
30
33
 
31
34
  __all__ = ["__version__"]