python-libei 0.3.0__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
libei/__init__.py CHANGED
@@ -26,9 +26,9 @@ evdev keycodes -- key positions, not characters -- though ``text_utf8()``
26
26
  sends characters directly where libei 1.6 is available. See the README for
27
27
  the full breakdown, including which features need which libei version.
28
28
 
29
- Alpha: the API is not frozen.
29
+ Beta: the API is not frozen.
30
30
  """
31
31
 
32
- __version__ = "0.3.0"
32
+ __version__ = "0.4.0"
33
33
 
34
34
  __all__ = ["__version__"]
libei/portal.py CHANGED
@@ -63,6 +63,7 @@ from __future__ import annotations
63
63
  import enum
64
64
  import logging
65
65
  import os
66
+ import time
66
67
  import uuid
67
68
  from typing import Any
68
69
 
@@ -95,6 +96,23 @@ to see and answer the consent dialog `Start` raises -- but bounded, because
95
96
  the alternative is a caller wedged forever if the portal dies after
96
97
  accepting the call and before sending its `Response`."""
97
98
 
99
+ _GIO_DEFAULT_TIMEOUT_MSEC = 25_000
100
+ """What GDBus's ``-1`` reply timeout actually is.
101
+
102
+ ``call_sync(..., -1, ...)`` does not mean "wait forever" -- that is
103
+ ``G_MAXINT`` -- it means GIO's own default, which is 25 seconds. Only used
104
+ to report the right number when a call left on that default runs out of
105
+ time; see `_call_sync`."""
106
+
107
+ _G_IO_ERROR_DOMAIN = "g-io-error-quark"
108
+ """The GLib error domain GDBus reports its own reply timeout in.
109
+
110
+ Not one of the ``org.freedesktop.DBus.Error.*`` codes, as one might expect:
111
+ a ``call_sync()`` whose reply never arrives raises ``G_IO_ERROR_TIMED_OUT``
112
+ in this domain with the message "Timeout was reached" (checked against a
113
+ service that accepts a call and then deliberately never answers). See
114
+ `_is_reply_timeout`."""
115
+
98
116
  _ALL_DEVICE_TYPES = DeviceType.KEYBOARD | DeviceType.POINTER | DeviceType.TOUCHSCREEN
99
117
  """Every bit the RemoteDesktop `types` bitmask defines.
100
118
 
@@ -189,6 +207,43 @@ def _glib_error(GLib: Any) -> Any:
189
207
  return getattr(GLib, "Error", ())
190
208
 
191
209
 
210
+ def _is_reply_timeout(Gio: Any, exc: Exception) -> bool:
211
+ """Whether a GDBus failure is its own reply timeout expiring.
212
+
213
+ Worth telling apart from every other ``GLib.Error``, because it means
214
+ exactly what the nested loop's own timeout means -- nobody answered in
215
+ the time allowed -- and so should raise the `PortalTimeoutError` a
216
+ caller is documented to catch for that, not a generic `PortalError`.
217
+
218
+ Defensive throughout: a test double standing in for ``Gio`` need not
219
+ define ``IOErrorEnum``, and one standing in for ``GLib.Error`` need not
220
+ carry a domain or a code. An attribute that isn't there just means
221
+ "not a timeout", which is the safe answer -- the failure still reaches
222
+ the caller, only as the more general error.
223
+ """
224
+ timed_out = getattr(getattr(Gio, "IOErrorEnum", None), "TIMED_OUT", None)
225
+ if timed_out is None:
226
+ return False
227
+ return (
228
+ getattr(exc, "domain", None) == _G_IO_ERROR_DOMAIN
229
+ and getattr(exc, "code", None) == timed_out
230
+ )
231
+
232
+
233
+ def _msec_until(deadline: float) -> int:
234
+ """Milliseconds left before ``deadline``, never negative.
235
+
236
+ Both legs of a round trip -- the call that returns the request handle,
237
+ then the wait for its ``Response`` signal -- draw down the same
238
+ deadline, so ``timeout`` bounds the round trip rather than each half of
239
+ it separately, which would let a slow first leg quietly double the wait
240
+ a caller asked for. Zero is a legitimate answer: GLib fires a 0ms
241
+ timeout on the next main-loop iteration, which is the right thing for a
242
+ deadline that has already passed.
243
+ """
244
+ return max(0, int((deadline - time.monotonic()) * 1000))
245
+
246
+
192
247
  def _call_sync(
193
248
  connection: Any,
194
249
  Gio: Any,
@@ -199,6 +254,7 @@ def _call_sync(
199
254
  method: str,
200
255
  parameters: Any,
201
256
  reply_type: Any,
257
+ timeout_msec: int = -1,
202
258
  ) -> Any:
203
259
  """``call_sync``, with GDBus failures translated to `PortalError`.
204
260
 
@@ -206,6 +262,13 @@ def _call_sync(
206
262
  no-portal-backend cases -- exactly the ones `is_available()` documents
207
263
  as surfacing here, since it deliberately checks neither -- escape a
208
264
  caller's `except PortalError`.
265
+
266
+ ``timeout_msec`` is GDBus's own reply timeout, and defaults to its
267
+ ``-1`` -- GIO's 25 seconds (see `_GIO_DEFAULT_TIMEOUT_MSEC`), which is
268
+ what `RemoteDesktopSession.close` leaves it at, having no caller-facing
269
+ timeout to honour. Anything reached from `negotiate` passes the
270
+ caller's own ``timeout`` instead, so that the bound on a round trip is
271
+ the documented one rather than a number this module never chose.
209
272
  """
210
273
  try:
211
274
  return connection.call_sync(
@@ -216,13 +279,47 @@ def _call_sync(
216
279
  parameters,
217
280
  reply_type,
218
281
  Gio.DBusCallFlags.NONE,
219
- -1,
282
+ timeout_msec,
220
283
  None,
221
284
  )
222
285
  except _glib_error(GLib) as exc:
286
+ if _is_reply_timeout(Gio, exc):
287
+ expired = timeout_msec if timeout_msec >= 0 else _GIO_DEFAULT_TIMEOUT_MSEC
288
+ raise PortalTimeoutError(method, expired / 1000) from exc
223
289
  raise PortalError(f"{method} failed on the D-Bus: {exc}") from exc
224
290
 
225
291
 
292
+ def _close_session(
293
+ connection: Any,
294
+ Gio: Any,
295
+ GLib: Any,
296
+ busname: str,
297
+ session_handle: str,
298
+ ) -> None:
299
+ """Call ``Session.Close()``, logging rather than raising on failure.
300
+
301
+ Shared by `RemoteDesktopSession.close` and `negotiate`'s failure path,
302
+ which need the same thing of it: the session may already be gone (the
303
+ portal restarted, the user revoked the grant), and neither a caller
304
+ tidying up nor an exception already unwinding is helped by a second
305
+ failure thrown over the top of the first.
306
+ """
307
+ try:
308
+ _call_sync(
309
+ connection,
310
+ Gio,
311
+ GLib,
312
+ busname,
313
+ session_handle,
314
+ _SESSION_INTERFACE,
315
+ "Close",
316
+ None,
317
+ None,
318
+ )
319
+ except PortalError as exc:
320
+ logger.debug("closing the portal session failed: %s", exc)
321
+
322
+
226
323
  def _returned_handle(reply: Any) -> str | None:
227
324
  """The request object path a Request-returning call replied with.
228
325
 
@@ -269,11 +366,16 @@ def _request(
269
366
  call at all -- the pattern xdg-desktop-portal's own documentation
270
367
  describes.
271
368
 
272
- Raises :class:`PortalTimeoutError` if no ``Response`` arrives within
273
- ``timeout``. The nested loop is otherwise unbounded, and a portal that
274
- dies after accepting the call sends no ``Response`` and no error --
275
- leaving the caller wedged with nothing to poll and no way out.
369
+ Raises :class:`PortalTimeoutError` if the whole round trip -- the call
370
+ that returns the request handle, then the ``Response`` that answers it
371
+ -- exceeds ``timeout``. The nested loop is otherwise unbounded, and a
372
+ portal that dies after accepting the call sends no ``Response`` and no
373
+ error, leaving the caller wedged with nothing to poll and no way out.
374
+ Both legs share one deadline (see `_msec_until`), since a caller asking
375
+ for 60 seconds means the answer arrives inside 60 seconds, not inside
376
+ however many 60-second waits the sequence happens to be built from.
276
377
  """
378
+ deadline = time.monotonic() + timeout
277
379
  unique_name = connection.get_unique_name()
278
380
  escaped_sender = unique_name[1:].replace(".", "_")
279
381
  token = uuid.uuid4().hex
@@ -337,6 +439,7 @@ def _request(
337
439
  method,
338
440
  parameters,
339
441
  None,
442
+ _msec_until(deadline),
340
443
  )
341
444
  # The spec says the handle the call returns matches the path derived
342
445
  # from our own handle_token, but a portal is free to hand back
@@ -353,7 +456,7 @@ def _request(
353
456
  # is not running yet does not stop the later run(), so running it
354
457
  # then would block with the reply already delivered.
355
458
  if not result:
356
- timeout_source = GLib.timeout_add(int(timeout * 1000), on_timeout)
459
+ timeout_source = GLib.timeout_add(_msec_until(deadline), on_timeout)
357
460
  try:
358
461
  loop.run()
359
462
  finally:
@@ -377,12 +480,18 @@ def _call_for_fd(
377
480
  interface: str,
378
481
  method: str,
379
482
  session_handle: str,
483
+ timeout: float,
380
484
  ) -> int:
381
485
  """Call a method that returns a fd via a GUnixFDList index.
382
486
 
383
487
  The fd that comes back is *owned* -- `g_unix_fd_list_get()` dups it --
384
488
  so whoever receives it has to close it. See
385
489
  :meth:`RemoteDesktopSession.close`.
490
+
491
+ ``ConnectToEIS`` returns no `Request`, so there is no ``Response`` to
492
+ wait on and nothing here beyond the call itself -- but it is a call to
493
+ the same portal that just made the caller wait on a consent dialog, so
494
+ it is bounded by the same ``timeout`` rather than by GIO's default.
386
495
  """
387
496
  try:
388
497
  reply, fd_list = connection.call_with_unix_fd_list_sync(
@@ -393,17 +502,22 @@ def _call_for_fd(
393
502
  GLib.Variant("(oa{sv})", (session_handle, {})),
394
503
  GLib.VariantType.new("(h)"),
395
504
  Gio.DBusCallFlags.NONE,
396
- -1,
505
+ int(timeout * 1000),
397
506
  None,
398
507
  None,
399
508
  )
400
509
  except _glib_error(GLib) as exc:
510
+ if _is_reply_timeout(Gio, exc):
511
+ raise PortalTimeoutError(method, timeout) from exc
401
512
  raise PortalError(f"{method} failed on the D-Bus: {exc}") from exc
402
513
  (handle_index,) = reply.unpack()
403
514
  return fd_list.get(handle_index)
404
515
 
405
516
 
406
- def _remote_desktop_version(connection: Any, Gio: Any, GLib: Any, busname: str) -> int:
517
+ def _remote_desktop_version(
518
+ connection: Any, Gio: Any, GLib: Any, busname: str, timeout: float
519
+ ) -> int:
520
+ """Read the RemoteDesktop portal's ``version`` property."""
407
521
  reply = _call_sync(
408
522
  connection,
409
523
  Gio,
@@ -414,6 +528,7 @@ def _remote_desktop_version(connection: Any, Gio: Any, GLib: Any, busname: str)
414
528
  "Get",
415
529
  GLib.Variant("(ss)", (_REMOTE_DESKTOP, "version")),
416
530
  None,
531
+ int(timeout * 1000),
417
532
  )
418
533
  (version,) = reply.unpack()
419
534
  return int(version)
@@ -446,10 +561,15 @@ class RemoteDesktopSession:
446
561
  eis_fd: int,
447
562
  restore_token: str | None,
448
563
  session_handle: str | None = None,
564
+ busname: str = _BUS_NAME,
449
565
  ) -> None:
450
566
  self._connection = connection
451
567
  self._eis_fd: int | None = eis_fd
452
568
  self._session_handle = session_handle
569
+ # Whichever bus name negotiate() used, not the default: a session
570
+ # created on an alternate portal name has to be closed on that same
571
+ # name, or Session.Close() goes to a portal that never heard of it.
572
+ self._busname = busname
453
573
  # Mirrors libei.oeffis.Oeffis's ownership rule: reading `eis_fd`
454
574
  # hands the fd to the caller, so close() must not also close it once
455
575
  # that has happened -- but nothing else will ever close it if the
@@ -516,19 +636,13 @@ class RemoteDesktopSession:
516
636
  return
517
637
  Gio, GLib = gio_modules
518
638
  try:
519
- _call_sync(
639
+ _close_session(
520
640
  self._connection,
521
641
  Gio,
522
642
  GLib,
523
- _BUS_NAME,
643
+ self._busname,
524
644
  self._session_handle,
525
- _SESSION_INTERFACE,
526
- "Close",
527
- None,
528
- None,
529
645
  )
530
- except PortalError as exc:
531
- logger.debug("closing the portal session failed: %s", exc)
532
646
  finally:
533
647
  self._session_handle = None
534
648
  self._connection = None
@@ -578,7 +692,11 @@ class RemoteDesktopSession:
578
692
  :class:`PortalDeniedError` if any step is declined, and
579
693
  :class:`PortalTimeoutError` if any one round trip exceeds
580
694
  ``timeout`` seconds (60 by default -- generous, since ``Start``
581
- waits on a human answering a dialog).
695
+ waits on a human answering a dialog). Any of those raised after
696
+ ``CreateSession`` has succeeded closes the session it created on
697
+ the way out; nothing else could, since no `RemoteDesktopSession`
698
+ exists yet to own it and the portal would keep it alive for the
699
+ life of the bus connection.
582
700
 
583
701
  ``devices`` selects what to ask for. ``DeviceType.ALL_DEVICES`` is
584
702
  liboeffis's sentinel for "everything" and is literally ``0``, which
@@ -628,7 +746,7 @@ class RemoteDesktopSession:
628
746
  except _glib_error(GLib) as exc:
629
747
  raise PortalError(f"cannot reach the session bus: {exc}") from exc
630
748
 
631
- version = _remote_desktop_version(connection, Gio, GLib, busname)
749
+ version = _remote_desktop_version(connection, Gio, GLib, busname, timeout)
632
750
  if version < _MIN_REMOTE_DESKTOP_VERSION:
633
751
  raise PortalVersionError(
634
752
  f"RemoteDesktop version {version} is too old for "
@@ -653,8 +771,60 @@ class RemoteDesktopSession:
653
771
  )
654
772
  if code != 0:
655
773
  raise PortalDeniedError("CreateSession")
656
- session_handle = results["session_handle"]
774
+ session_handle = results.get("session_handle")
775
+ if not isinstance(session_handle, str):
776
+ # Approved, but with no handle to address the rest of the
777
+ # sequence to. `results` is portal-supplied data like any other
778
+ # reply, so a malformed one fails the way the rest of this
779
+ # module's do -- rather than as the KeyError a direct index
780
+ # would raise straight past a caller's `except PortalError`.
781
+ raise PortalError("CreateSession returned no session_handle")
782
+
783
+ # Past this point a session exists inside xdg-desktop-portal, and
784
+ # nothing else can close it: no RemoteDesktopSession owns it yet,
785
+ # and the connection it was created on is GLib's shared session-bus
786
+ # singleton, which outlives this failure rather than taking the
787
+ # session down with it. So every exit from here closes it.
788
+ try:
789
+ return cls._connect_devices(
790
+ connection,
791
+ Gio,
792
+ GLib,
793
+ busname,
794
+ session_handle,
795
+ devices=devices,
796
+ persist_mode=persist_mode,
797
+ restore_token=restore_token,
798
+ timeout=timeout,
799
+ )
800
+ except BaseException:
801
+ # BaseException, not Exception: `Start` blocks on a human
802
+ # answering a consent dialog, so Ctrl-C during that wait is a
803
+ # routine way out of this function -- and it strands an
804
+ # approved session exactly as a decline does.
805
+ _close_session(connection, Gio, GLib, busname, session_handle)
806
+ raise
807
+
808
+ @classmethod
809
+ def _connect_devices(
810
+ cls,
811
+ connection: Any,
812
+ Gio: Any,
813
+ GLib: Any,
814
+ busname: str,
815
+ session_handle: str,
816
+ *,
817
+ devices: DeviceType,
818
+ persist_mode: PersistMode,
819
+ restore_token: str | None,
820
+ timeout: float,
821
+ ) -> RemoteDesktopSession:
822
+ """``SelectDevices`` -> ``Start`` -> ``ConnectToEIS``, given a session.
657
823
 
824
+ Split out of :meth:`negotiate` only so that the caller can wrap the
825
+ whole of it in one ``try`` -- every step here can fail, and every
826
+ one of those failures leaves the same session behind to be closed.
827
+ """
658
828
  # ALL_DEVICES is 0, which SelectDevices reads as "no device types"
659
829
  # rather than "every device type" -- see _ALL_DEVICE_TYPES.
660
830
  types = _ALL_DEVICE_TYPES if devices == DeviceType.ALL_DEVICES else devices
@@ -704,5 +874,12 @@ class RemoteDesktopSession:
704
874
  _REMOTE_DESKTOP,
705
875
  "ConnectToEIS",
706
876
  session_handle,
877
+ timeout,
878
+ )
879
+ return cls(
880
+ connection,
881
+ eis_fd,
882
+ new_restore_token,
883
+ session_handle,
884
+ busname,
707
885
  )
708
- return cls(connection, eis_fd, new_restore_token, session_handle)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-libei
3
- Version: 0.3.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
@@ -9,7 +9,7 @@ Project-URL: repository, https://github.com/ctrondlp/python-libei
9
9
  Project-URL: issues, https://github.com/ctrondlp/python-libei/issues
10
10
  Project-URL: changelog, https://github.com/ctrondlp/python-libei/blob/main/CHANGELOG.md
11
11
  Keywords: wayland,libei,libeis,liboeffis,input,input-emulation,emulated-input,portal,xdg-desktop-portal,remote-desktop,automation,gui-testing,accessibility,ctypes
12
- Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Development Status :: 4 - Beta
13
13
  Classifier: Intended Audience :: Developers
14
14
  Classifier: Operating System :: POSIX :: Linux
15
15
  Classifier: Programming Language :: Python :: 3
@@ -136,7 +136,7 @@ pointer.
136
136
 
137
137
  ## Status
138
138
 
139
- Alpha (`0.3.0`), published on [PyPI](https://pypi.org/project/python-libei/)
139
+ Beta (`0.4.0`), published on [PyPI](https://pypi.org/project/python-libei/)
140
140
  since `0.1.0`, and the API is not frozen — expect renames before 1.0. What
141
141
  that qualifier covers, concretely:
142
142
 
@@ -151,7 +151,8 @@ that qualifier covers, concretely:
151
151
  verified by hand, since they need an interactive consent dialog that
152
152
  nothing here can drive automatically — `tests/test_portal.py` covers
153
153
  `libei.portal`'s orchestration (raceless subscribe-before-call, the
154
- `session_handle_token` crash workaround, persist_mode/restore_token)
154
+ `session_handle_token` crash workaround, persist_mode/restore_token, and
155
+ closing the portal session on a negotiation that fails part-way)
155
156
  against a fake D-Bus connection only. `libei.oeffis` was verified by hand
156
157
  on 2026-08-25 (see [Troubleshooting](#troubleshooting)), and
157
158
  `libei.portal` on 2026-09-01 against a real GNOME Wayland session
@@ -1,17 +1,17 @@
1
- libei/__init__.py,sha256=ckwB3oooo2C06cgm5CFk182ieSwvWwcRtazRfmEs6nw,1676
1
+ libei/__init__.py,sha256=TNtT-iro7nzQKoATg37_LGuxqeOai0nbD8Cmw3FvhDY,1675
2
2
  libei/_cobject.py,sha256=msrbviABSWjc5fKsXHSCg7nQ0Y4Etcq_Dkh0fblW-sY,12845
3
3
  libei/ei.py,sha256=Da3ezXnFPXtejlcJFH8bcE5f4pxKZXXqb6ltCh2zAdE,46704
4
4
  libei/eis.py,sha256=aYWOCN1NlRchcWd6NjIUi1eYMFWJbp0UHMcQFEJ0ii4,43642
5
5
  libei/oeffis.py,sha256=UcnDwLErFxB0xuktNpIzAK01ylpl6ZrmaGnWiUOcFOo,9302
6
- libei/portal.py,sha256=Vf0HeI5kjvTBlCv5P531ugDqclMdL5o9Qru_T80VFq4,27679
6
+ libei/portal.py,sha256=K74g5yhnlo0E1arGvoyD24zRnkKk2Th90-xEeg9wO_M,35342
7
7
  libei/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
8
  libei/_capi/__init__.py,sha256=3VxixYYlr_ZcuiK0GwrCpbE3VvEv_O990mJzdIJ8i74,209
9
9
  libei/_capi/libei.py,sha256=gpp42bhqPonhVD2zmFWCl4rdk_lm4XvD_7W2Rw5RsuI,11308
10
10
  libei/_capi/libeis.py,sha256=jQR0Z0YN9qa71mAcSJ__-UaoF8PsVsBs8OXwjfqstFk,13080
11
11
  libei/_capi/liboeffis.py,sha256=V8jdmJm3qOQUzzNiCAXt4Jr2JY11Y4-w6NIfIxbxGI4,1218
12
12
  libei/_capi/loader.py,sha256=k7fb_Nz0fg5QkuLJ_duKXj8bESSktLQ2ZyS5LNQ5v2I,4689
13
- python_libei-0.3.0.dist-info/licenses/LICENSE,sha256=l6xbMU6Y-JZDzmBciBj-J5t6h6jNg_Bcyp4bHxDepqU,1074
14
- python_libei-0.3.0.dist-info/METADATA,sha256=ATlj4wSdgfKN1MOaeQa8h27ss1xC3FxrouZ3hZqSWAI,40033
15
- python_libei-0.3.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
16
- python_libei-0.3.0.dist-info/top_level.txt,sha256=_DQXzGjDsUBENI_cNkiOxPB4xi8coCbQS1lq18FMudQ,6
17
- python_libei-0.3.0.dist-info/RECORD,,
13
+ python_libei-0.4.0.dist-info/licenses/LICENSE,sha256=l6xbMU6Y-JZDzmBciBj-J5t6h6jNg_Bcyp4bHxDepqU,1074
14
+ python_libei-0.4.0.dist-info/METADATA,sha256=_Vi9zTSjWz3Eho4gqQFjpdZzosdKa8-zQveksRDfYbU,40102
15
+ python_libei-0.4.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
16
+ python_libei-0.4.0.dist-info/top_level.txt,sha256=_DQXzGjDsUBENI_cNkiOxPB4xi8coCbQS1lq18FMudQ,6
17
+ python_libei-0.4.0.dist-info/RECORD,,