python-libei 0.2.0__tar.gz → 0.4.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.2.0/src/python_libei.egg-info → python_libei-0.4.0}/PKG-INFO +84 -21
  2. {python_libei-0.2.0 → python_libei-0.4.0}/README.md +79 -19
  3. {python_libei-0.2.0 → python_libei-0.4.0}/pyproject.toml +17 -2
  4. {python_libei-0.2.0 → python_libei-0.4.0}/src/libei/__init__.py +5 -2
  5. python_libei-0.4.0/src/libei/portal.py +885 -0
  6. {python_libei-0.2.0 → python_libei-0.4.0/src/python_libei.egg-info}/PKG-INFO +84 -21
  7. {python_libei-0.2.0 → python_libei-0.4.0}/src/python_libei.egg-info/SOURCES.txt +3 -1
  8. {python_libei-0.2.0 → python_libei-0.4.0}/src/python_libei.egg-info/requires.txt +3 -0
  9. python_libei-0.4.0/tests/test_portal.py +919 -0
  10. {python_libei-0.2.0 → python_libei-0.4.0}/LICENSE +0 -0
  11. {python_libei-0.2.0 → python_libei-0.4.0}/setup.cfg +0 -0
  12. {python_libei-0.2.0 → python_libei-0.4.0}/src/libei/_capi/__init__.py +0 -0
  13. {python_libei-0.2.0 → python_libei-0.4.0}/src/libei/_capi/libei.py +0 -0
  14. {python_libei-0.2.0 → python_libei-0.4.0}/src/libei/_capi/libeis.py +0 -0
  15. {python_libei-0.2.0 → python_libei-0.4.0}/src/libei/_capi/liboeffis.py +0 -0
  16. {python_libei-0.2.0 → python_libei-0.4.0}/src/libei/_capi/loader.py +0 -0
  17. {python_libei-0.2.0 → python_libei-0.4.0}/src/libei/_cobject.py +0 -0
  18. {python_libei-0.2.0 → python_libei-0.4.0}/src/libei/ei.py +0 -0
  19. {python_libei-0.2.0 → python_libei-0.4.0}/src/libei/eis.py +0 -0
  20. {python_libei-0.2.0 → python_libei-0.4.0}/src/libei/oeffis.py +0 -0
  21. {python_libei-0.2.0 → python_libei-0.4.0}/src/libei/py.typed +0 -0
  22. {python_libei-0.2.0 → python_libei-0.4.0}/src/python_libei.egg-info/dependency_links.txt +0 -0
  23. {python_libei-0.2.0 → python_libei-0.4.0}/src/python_libei.egg-info/top_level.txt +0 -0
  24. {python_libei-0.2.0 → python_libei-0.4.0}/tests/test_cobject.py +0 -0
  25. {python_libei-0.2.0 → python_libei-0.4.0}/tests/test_documentation_shape.py +0 -0
  26. {python_libei-0.2.0 → python_libei-0.4.0}/tests/test_documented_examples.py +0 -0
  27. {python_libei-0.2.0 → python_libei-0.4.0}/tests/test_ei_objects.py +0 -0
  28. {python_libei-0.2.0 → python_libei-0.4.0}/tests/test_eis_objects.py +0 -0
  29. {python_libei-0.2.0 → python_libei-0.4.0}/tests/test_integration_extras.py +0 -0
  30. {python_libei-0.2.0 → python_libei-0.4.0}/tests/test_integration_socketpair.py +0 -0
  31. {python_libei-0.2.0 → python_libei-0.4.0}/tests/test_loader.py +0 -0
  32. {python_libei-0.2.0 → python_libei-0.4.0}/tests/test_oeffis.py +0 -0
@@ -1,14 +1,15 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-libei
3
- Version: 0.2.0
3
+ Version: 0.4.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
- Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Development Status :: 4 - Beta
12
13
  Classifier: Intended Audience :: Developers
13
14
  Classifier: Operating System :: POSIX :: Linux
14
15
  Classifier: Programming Language :: Python :: 3
@@ -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,7 +136,7 @@ pointer.
133
136
 
134
137
  ## Status
135
138
 
136
- Alpha (`0.2.0`), published on [PyPI](https://pypi.org/project/python-libei/)
139
+ Beta (`0.4.0`), published on [PyPI](https://pypi.org/project/python-libei/)
137
140
  since `0.1.0`, and the API is not frozen — expect renames before 1.0. What
138
141
  that qualifier covers, concretely:
139
142
 
@@ -144,9 +147,21 @@ that qualifier covers, concretely:
144
147
  - Text input, touch cancellation, ping/pong, keymap transfer, region mapping
145
148
  ids and `peek_event_type()` are each round-tripped through a real libeis
146
149
  server in `tests/test_integration_extras.py`.
147
- - The portal path (`libei.oeffis`) has only ever been verified by hand, since
148
- it needs an interactive consent dialog that nothing here can drive. See
149
- [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, and
155
+ closing the portal session on a negotiation that fails part-way)
156
+ against a fake D-Bus connection only. `libei.oeffis` was verified by hand
157
+ on 2026-08-25 (see [Troubleshooting](#troubleshooting)), and
158
+ `libei.portal` on 2026-09-01 against a real GNOME Wayland session
159
+ (`RemoteDesktop` v2): a first run raised the consent dialog and was
160
+ approved (5.4s), a second replaying the `restore_token` was granted with
161
+ no dialog at all (0.2s), three devices resumed on the returned fd
162
+ (relative pointer, keyboard, absolute pointer — in that order, the device
163
+ race `ei`-side callers must handle), and `Session.Close()` was exercised.
164
+ No input was injected — emulation is `libei.ei`'s job.
150
165
  - Verified against libei 1.6.0 on Fedora 44 / GNOME 50.4, and against a
151
166
  locally built 1.2.1 (130 passed, 4 skipped — the 1.4 and 1.6 features
152
167
  gate themselves out). CI repeats the 1.2.1 run on Python 3.10-3.13, so
@@ -158,7 +173,7 @@ that qualifier covers, concretely:
158
173
  | Instead of this | Why you might |
159
174
  | --- | --- |
160
175
  | [snegg](https://gitlab.freedesktop.org/whot/snegg) | The reference bindings, by libei's own author — closer to upstream, and first to get new API. Self-described as for "rapid prototyping" with an explicitly unstable API, and `import snegg.ei` fails outright where libei isn't installed. [`docs/vs-snegg.md`](docs/vs-snegg.md) covers the differences in detail. |
161
- | 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. |
176
+ | The RemoteDesktop portal's own D-Bus API directly (`NotifyPointerMotion`, `NotifyKeyboardKeycode`, …), bypassing libei/EI entirely | `libei.portal` already gets you the D-Bus session and its `persist_mode`/`restore_token` handling — reach past libei entirely only if you don't want the EI protocol at all. The catch if you do: `NotifyPointerMotionAbsolute` needs a PipeWire stream id, which only exists after a second, separate ScreenCast consent dialog. libei has no such requirement. |
162
177
  | `ydotool` and other `/dev/uinput` tools | Kernel-level, so they work under any compositor and need no portal session — at the cost of a privileged daemon, and of sidestepping the consent model that EI exists to enforce. |
163
178
 
164
179
  ## Requirements
@@ -166,6 +181,10 @@ that qualifier covers, concretely:
166
181
  - Linux with a Wayland compositor (GNOME, KDE, Sway, …)
167
182
  - CPython 3.10 or newer (tested on 3.13)
168
183
  - The native libraries: on Fedora, `sudo dnf install libei libeis liboeffis`
184
+ - `libei.portal` only: PyGObject (`pip install 'python-libei[portal]'`), plus
185
+ whatever GObject-introspection libraries your distro needs for `Gio` --
186
+ PyPI's PyGObject wheel supplies the Python side only. Not needed for
187
+ `libei.ei`, `libei.eis` or `libei.oeffis`.
169
188
  - libei 1.0.0 or newer for the core: connecting, binding a seat, and
170
189
  sending pointer, button, keyboard, scroll and touch input all use symbols
171
190
  that have existed with a stable signature since 1.0.0, and upstream keeps
@@ -301,23 +320,59 @@ several devices — see the absolute-positioning notes under
301
320
  call and exposes no options dict, so the two things that make an approval
302
321
  persist — `persist_mode` on `SelectDevices`, and the `restore_token` that
303
322
  comes back on `Start` — are unreachable through it. This is a limitation of
304
- the C library, not of these bindings; there is nothing here left to bind.
323
+ the C library, not of these bindings; upstream's own docs say as much:
324
+ liboeffis is "intentionally kept simple, any more complex needs should be
325
+ handled by an application talking to DBus directly"
326
+ ([source](https://libinput.pages.freedesktop.org/libei/api/group__liboeffis.html)).
305
327
 
306
- What works is negotiating the portal yourself over D-Bus and handing the
307
- resulting fd to `Sender.create_for_fd()`, which does not care where the fd
308
- came from:
328
+ `libei.portal` is that: the same `CreateSession` → `SelectDevices` → `Start`
329
+ → `ConnectToEIS` sequence, driven directly over D-Bus (needs PyGObject —
330
+ `pip install 'python-libei[portal]'`), with `persist_mode`/`restore_token`
331
+ as real parameters:
309
332
 
310
- 1. `CreateSession` on `org.freedesktop.portal.RemoteDesktop`
311
- 2. `SelectDevices` with `persist_mode` (1 = while running, 2 = until
312
- revoked) and, on later runs, the saved `restore_token`
313
- 3. `Start` — the response carries a fresh `restore_token`, which you store
314
- 4. `ConnectToEIS` — the fd for `ei.Sender.create_for_fd()`
333
+ ```python
334
+ from libei import ei, portal
335
+
336
+ with portal.RemoteDesktopSession.negotiate(
337
+ devices=portal.DeviceType.POINTER,
338
+ persist_mode=portal.PersistMode.UNTIL_REVOKED,
339
+ restore_token=saved_token, # None on the first run
340
+ ) as session:
341
+ save_somewhere(session.restore_token) # a fresh token every time -- save it
342
+ sender = ei.Sender.create_for_fd(session.eis_fd, name="my-app")
343
+ ... # inject input for as long as the session is needed
344
+ ```
315
345
 
316
346
  Save the token somewhere durable and pass it back next time; the portal then
317
347
  restores the session without prompting. Treat it as a credential — anyone
318
348
  holding it can reopen input injection on that desktop, so it belongs
319
349
  wherever you'd keep a password, and the decision to store it at all belongs
320
- to the application rather than to a library.
350
+ to the application rather than to this library, which never writes it
351
+ anywhere itself.
352
+
353
+ Save whatever comes back on **every** run, not just the first: the portal is
354
+ free to hand back a different token each time, and a caller that keeps only
355
+ the original would eventually present a stale one. (On GNOME the same token
356
+ comes back on each restore — that is one portal's behaviour, not a
357
+ guarantee.) Passing `restore_token` *without* a `persist_mode` raises
358
+ `ValueError`: the portal answers such a request with no token at all, so
359
+ storing what came back would write `None` over the token you just spent.
360
+
361
+ Three differences from `Oeffis` above worth knowing:
362
+
363
+ - **Blocking, not event-driven.** `negotiate()` runs its own nested
364
+ `GLib.MainLoop` per D-Bus round trip and returns only once connected, or
365
+ raises `PortalVersionError` / `PortalDeniedError` / `PortalTimeoutError`.
366
+ - **Bounded.** Each round trip gets `timeout` seconds (60 by default —
367
+ generous, since `Start` waits on a human answering a dialog). Without it a
368
+ portal that dies after accepting the call would wedge the calling thread
369
+ forever, which is the one thing `Oeffis`'s pollable fd protects against.
370
+ - **Close it.** The portal session lives in xdg-desktop-portal and outlives
371
+ the object unless `Session.Close()` is called — `Gio.bus_get_sync()` hands
372
+ back GLib's *shared* connection, so dropping the session tears nothing
373
+ down, and a long-running process that negotiates repeatedly accumulates
374
+ live sessions. The `with` block above handles it; otherwise call
375
+ `session.close()`.
321
376
 
322
377
  ## Sending input
323
378
 
@@ -646,6 +701,7 @@ the capability you bound (`seat.capabilities`).
646
701
  | `libei.ei` | Clients: `Sender` (inject), `Receiver` (consume) |
647
702
  | `libei.eis` | Servers: `Eis`, for compositors and for testing clients |
648
703
  | `libei.oeffis` | Getting an EI fd from the desktop portal |
704
+ | `libei.portal` | The same, over D-Bus directly, with `persist_mode`/`restore_token` |
649
705
 
650
706
  Each module has `is_available()`, an `Error` exception, and an `EventType` /
651
707
  `DeviceCapability` enum. `ei` and `eis` also share the shapes around them:
@@ -670,6 +726,12 @@ them in this order -- each one only makes sense once the one below it does.
670
726
  | [`_cobject.py`](src/libei/_cobject.py) | `CObject`: pointer ownership, refcounting, and the identity cache that every wrapper class inherits |
671
727
  | [`ei.py`](src/libei/ei.py), [`eis.py`](src/libei/eis.py), [`oeffis.py`](src/libei/oeffis.py) | The public API: Python classes, enums and dataclasses over the raw calls |
672
728
 
729
+ [`portal.py`](src/libei/portal.py) sits outside this stack entirely -- there
730
+ is no C library behind it, so no `_capi` binding and no `CObject`. It talks
731
+ D-Bus directly through PyGObject (`Gio`/`GLib`, imported lazily the same way
732
+ the C libraries are loaded lazily) and only ever produces a plain fd, which
733
+ is where it hands off to `ei.Sender.create_for_fd()`.
734
+
673
735
  **Read `_cobject.py` first.** It is the smallest file with the most
674
736
  consequence: get `wrap()` vs `adopt()`, the `staticmethod()` wrapping of
675
737
  `_ref_func`/`_unref_func`, or the `_wrappable` flag wrong and the failure is
@@ -734,16 +796,17 @@ Versions are SemVer and live in two places -- `pyproject.toml` and
734
796
  `src/libei/__init__.py` -- which have to agree with each other and with the
735
797
  tag. Nothing enforces that yet.
736
798
 
737
- A release is an annotated, `v`-prefixed tag plus a GitHub Release:
799
+ A release is an annotated, `v`-prefixed tag. Pushing it is the whole of it;
800
+ PyPI is the only place a release is published, and no GitHub Release is cut:
738
801
 
739
802
  ```sh
740
803
  git tag -a v0.2.0 -m "0.2.0"
741
804
  git push origin v0.2.0
742
- gh release create v0.2.0 --generate-notes --prerelease
743
805
  ```
744
806
 
745
- `--prerelease` while the API is unfrozen -- it keeps an alpha out of the
746
- "Latest release" slot.
807
+ While the API is unfrozen, the pre-release signal lives in the version
808
+ itself: a PEP 440 suffix (`0.2.0a1`) keeps a plain `pip install
809
+ python-libei` off it, and a `0.x` version already says the API can move.
747
810
 
748
811
  Publishing runs from CI on a `v*` tag using PyPI
749
812
  [Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (OIDC), so
@@ -100,7 +100,7 @@ pointer.
100
100
 
101
101
  ## Status
102
102
 
103
- Alpha (`0.2.0`), published on [PyPI](https://pypi.org/project/python-libei/)
103
+ Beta (`0.4.0`), published on [PyPI](https://pypi.org/project/python-libei/)
104
104
  since `0.1.0`, and the API is not frozen — expect renames before 1.0. What
105
105
  that qualifier covers, concretely:
106
106
 
@@ -111,9 +111,21 @@ that qualifier covers, concretely:
111
111
  - Text input, touch cancellation, ping/pong, keymap transfer, region mapping
112
112
  ids and `peek_event_type()` are each round-tripped through a real libeis
113
113
  server in `tests/test_integration_extras.py`.
114
- - The portal path (`libei.oeffis`) has only ever been verified by hand, since
115
- it needs an interactive consent dialog that nothing here can drive. See
116
- [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, and
119
+ closing the portal session on a negotiation that fails part-way)
120
+ against a fake D-Bus connection only. `libei.oeffis` was verified by hand
121
+ on 2026-08-25 (see [Troubleshooting](#troubleshooting)), and
122
+ `libei.portal` on 2026-09-01 against a real GNOME Wayland session
123
+ (`RemoteDesktop` v2): a first run raised the consent dialog and was
124
+ approved (5.4s), a second replaying the `restore_token` was granted with
125
+ no dialog at all (0.2s), three devices resumed on the returned fd
126
+ (relative pointer, keyboard, absolute pointer — in that order, the device
127
+ race `ei`-side callers must handle), and `Session.Close()` was exercised.
128
+ No input was injected — emulation is `libei.ei`'s job.
117
129
  - Verified against libei 1.6.0 on Fedora 44 / GNOME 50.4, and against a
118
130
  locally built 1.2.1 (130 passed, 4 skipped — the 1.4 and 1.6 features
119
131
  gate themselves out). CI repeats the 1.2.1 run on Python 3.10-3.13, so
@@ -125,7 +137,7 @@ that qualifier covers, concretely:
125
137
  | Instead of this | Why you might |
126
138
  | --- | --- |
127
139
  | [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. |
128
- | 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. |
140
+ | 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. |
129
141
  | `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. |
130
142
 
131
143
  ## Requirements
@@ -133,6 +145,10 @@ that qualifier covers, concretely:
133
145
  - Linux with a Wayland compositor (GNOME, KDE, Sway, …)
134
146
  - CPython 3.10 or newer (tested on 3.13)
135
147
  - The native libraries: on Fedora, `sudo dnf install libei libeis liboeffis`
148
+ - `libei.portal` only: PyGObject (`pip install 'python-libei[portal]'`), plus
149
+ whatever GObject-introspection libraries your distro needs for `Gio` --
150
+ PyPI's PyGObject wheel supplies the Python side only. Not needed for
151
+ `libei.ei`, `libei.eis` or `libei.oeffis`.
136
152
  - libei 1.0.0 or newer for the core: connecting, binding a seat, and
137
153
  sending pointer, button, keyboard, scroll and touch input all use symbols
138
154
  that have existed with a stable signature since 1.0.0, and upstream keeps
@@ -268,23 +284,59 @@ several devices — see the absolute-positioning notes under
268
284
  call and exposes no options dict, so the two things that make an approval
269
285
  persist — `persist_mode` on `SelectDevices`, and the `restore_token` that
270
286
  comes back on `Start` — are unreachable through it. This is a limitation of
271
- the C library, not of these bindings; there is nothing here left to bind.
287
+ the C library, not of these bindings; upstream's own docs say as much:
288
+ liboeffis is "intentionally kept simple, any more complex needs should be
289
+ handled by an application talking to DBus directly"
290
+ ([source](https://libinput.pages.freedesktop.org/libei/api/group__liboeffis.html)).
272
291
 
273
- What works is negotiating the portal yourself over D-Bus and handing the
274
- resulting fd to `Sender.create_for_fd()`, which does not care where the fd
275
- came from:
292
+ `libei.portal` is that: the same `CreateSession` → `SelectDevices` → `Start`
293
+ → `ConnectToEIS` sequence, driven directly over D-Bus (needs PyGObject —
294
+ `pip install 'python-libei[portal]'`), with `persist_mode`/`restore_token`
295
+ as real parameters:
276
296
 
277
- 1. `CreateSession` on `org.freedesktop.portal.RemoteDesktop`
278
- 2. `SelectDevices` with `persist_mode` (1 = while running, 2 = until
279
- revoked) and, on later runs, the saved `restore_token`
280
- 3. `Start` — the response carries a fresh `restore_token`, which you store
281
- 4. `ConnectToEIS` — the fd for `ei.Sender.create_for_fd()`
297
+ ```python
298
+ from libei import ei, portal
299
+
300
+ with portal.RemoteDesktopSession.negotiate(
301
+ devices=portal.DeviceType.POINTER,
302
+ persist_mode=portal.PersistMode.UNTIL_REVOKED,
303
+ restore_token=saved_token, # None on the first run
304
+ ) as session:
305
+ save_somewhere(session.restore_token) # a fresh token every time -- save it
306
+ sender = ei.Sender.create_for_fd(session.eis_fd, name="my-app")
307
+ ... # inject input for as long as the session is needed
308
+ ```
282
309
 
283
310
  Save the token somewhere durable and pass it back next time; the portal then
284
311
  restores the session without prompting. Treat it as a credential — anyone
285
312
  holding it can reopen input injection on that desktop, so it belongs
286
313
  wherever you'd keep a password, and the decision to store it at all belongs
287
- to the application rather than to a library.
314
+ to the application rather than to this library, which never writes it
315
+ anywhere itself.
316
+
317
+ Save whatever comes back on **every** run, not just the first: the portal is
318
+ free to hand back a different token each time, and a caller that keeps only
319
+ the original would eventually present a stale one. (On GNOME the same token
320
+ comes back on each restore — that is one portal's behaviour, not a
321
+ guarantee.) Passing `restore_token` *without* a `persist_mode` raises
322
+ `ValueError`: the portal answers such a request with no token at all, so
323
+ storing what came back would write `None` over the token you just spent.
324
+
325
+ Three differences from `Oeffis` above worth knowing:
326
+
327
+ - **Blocking, not event-driven.** `negotiate()` runs its own nested
328
+ `GLib.MainLoop` per D-Bus round trip and returns only once connected, or
329
+ raises `PortalVersionError` / `PortalDeniedError` / `PortalTimeoutError`.
330
+ - **Bounded.** Each round trip gets `timeout` seconds (60 by default —
331
+ generous, since `Start` waits on a human answering a dialog). Without it a
332
+ portal that dies after accepting the call would wedge the calling thread
333
+ forever, which is the one thing `Oeffis`'s pollable fd protects against.
334
+ - **Close it.** The portal session lives in xdg-desktop-portal and outlives
335
+ the object unless `Session.Close()` is called — `Gio.bus_get_sync()` hands
336
+ back GLib's *shared* connection, so dropping the session tears nothing
337
+ down, and a long-running process that negotiates repeatedly accumulates
338
+ live sessions. The `with` block above handles it; otherwise call
339
+ `session.close()`.
288
340
 
289
341
  ## Sending input
290
342
 
@@ -613,6 +665,7 @@ the capability you bound (`seat.capabilities`).
613
665
  | `libei.ei` | Clients: `Sender` (inject), `Receiver` (consume) |
614
666
  | `libei.eis` | Servers: `Eis`, for compositors and for testing clients |
615
667
  | `libei.oeffis` | Getting an EI fd from the desktop portal |
668
+ | `libei.portal` | The same, over D-Bus directly, with `persist_mode`/`restore_token` |
616
669
 
617
670
  Each module has `is_available()`, an `Error` exception, and an `EventType` /
618
671
  `DeviceCapability` enum. `ei` and `eis` also share the shapes around them:
@@ -637,6 +690,12 @@ them in this order -- each one only makes sense once the one below it does.
637
690
  | [`_cobject.py`](src/libei/_cobject.py) | `CObject`: pointer ownership, refcounting, and the identity cache that every wrapper class inherits |
638
691
  | [`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 |
639
692
 
693
+ [`portal.py`](src/libei/portal.py) sits outside this stack entirely -- there
694
+ is no C library behind it, so no `_capi` binding and no `CObject`. It talks
695
+ D-Bus directly through PyGObject (`Gio`/`GLib`, imported lazily the same way
696
+ the C libraries are loaded lazily) and only ever produces a plain fd, which
697
+ is where it hands off to `ei.Sender.create_for_fd()`.
698
+
640
699
  **Read `_cobject.py` first.** It is the smallest file with the most
641
700
  consequence: get `wrap()` vs `adopt()`, the `staticmethod()` wrapping of
642
701
  `_ref_func`/`_unref_func`, or the `_wrappable` flag wrong and the failure is
@@ -701,16 +760,17 @@ Versions are SemVer and live in two places -- `pyproject.toml` and
701
760
  `src/libei/__init__.py` -- which have to agree with each other and with the
702
761
  tag. Nothing enforces that yet.
703
762
 
704
- A release is an annotated, `v`-prefixed tag plus a GitHub Release:
763
+ A release is an annotated, `v`-prefixed tag. Pushing it is the whole of it;
764
+ PyPI is the only place a release is published, and no GitHub Release is cut:
705
765
 
706
766
  ```sh
707
767
  git tag -a v0.2.0 -m "0.2.0"
708
768
  git push origin v0.2.0
709
- gh release create v0.2.0 --generate-notes --prerelease
710
769
  ```
711
770
 
712
- `--prerelease` while the API is unfrozen -- it keeps an alpha out of the
713
- "Latest release" slot.
771
+ While the API is unfrozen, the pre-release signal lives in the version
772
+ itself: a PEP 440 suffix (`0.2.0a1`) keeps a plain `pip install
773
+ python-libei` off it, and a `0.x` version already says the API can move.
714
774
 
715
775
  Publishing runs from CI on a `v*` tag using PyPI
716
776
  [Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (OIDC), so
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-libei"
7
- version = "0.2.0"
7
+ version = "0.4.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"
@@ -27,7 +27,7 @@ keywords = [
27
27
  "ctypes",
28
28
  ]
29
29
  classifiers = [
30
- "Development Status :: 3 - Alpha",
30
+ "Development Status :: 4 - Beta",
31
31
  "Intended Audience :: Developers",
32
32
  "Operating System :: POSIX :: Linux",
33
33
  "Programming Language :: Python :: 3",
@@ -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
@@ -23,9 +26,9 @@ evdev keycodes -- key positions, not characters -- though ``text_utf8()``
23
26
  sends characters directly where libei 1.6 is available. See the README for
24
27
  the full breakdown, including which features need which libei version.
25
28
 
26
- Alpha: the API is not frozen.
29
+ Beta: the API is not frozen.
27
30
  """
28
31
 
29
- __version__ = "0.2.0"
32
+ __version__ = "0.4.0"
30
33
 
31
34
  __all__ = ["__version__"]