python-libei 0.3.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.3.0/src/python_libei.egg-info → python_libei-0.4.0}/PKG-INFO +5 -4
  2. {python_libei-0.3.0 → python_libei-0.4.0}/README.md +3 -2
  3. {python_libei-0.3.0 → python_libei-0.4.0}/pyproject.toml +2 -2
  4. {python_libei-0.3.0 → python_libei-0.4.0}/src/libei/__init__.py +2 -2
  5. {python_libei-0.3.0 → python_libei-0.4.0}/src/libei/portal.py +197 -20
  6. {python_libei-0.3.0 → python_libei-0.4.0/src/python_libei.egg-info}/PKG-INFO +5 -4
  7. {python_libei-0.3.0 → python_libei-0.4.0}/tests/test_portal.py +289 -2
  8. {python_libei-0.3.0 → python_libei-0.4.0}/LICENSE +0 -0
  9. {python_libei-0.3.0 → python_libei-0.4.0}/setup.cfg +0 -0
  10. {python_libei-0.3.0 → python_libei-0.4.0}/src/libei/_capi/__init__.py +0 -0
  11. {python_libei-0.3.0 → python_libei-0.4.0}/src/libei/_capi/libei.py +0 -0
  12. {python_libei-0.3.0 → python_libei-0.4.0}/src/libei/_capi/libeis.py +0 -0
  13. {python_libei-0.3.0 → python_libei-0.4.0}/src/libei/_capi/liboeffis.py +0 -0
  14. {python_libei-0.3.0 → python_libei-0.4.0}/src/libei/_capi/loader.py +0 -0
  15. {python_libei-0.3.0 → python_libei-0.4.0}/src/libei/_cobject.py +0 -0
  16. {python_libei-0.3.0 → python_libei-0.4.0}/src/libei/ei.py +0 -0
  17. {python_libei-0.3.0 → python_libei-0.4.0}/src/libei/eis.py +0 -0
  18. {python_libei-0.3.0 → python_libei-0.4.0}/src/libei/oeffis.py +0 -0
  19. {python_libei-0.3.0 → python_libei-0.4.0}/src/libei/py.typed +0 -0
  20. {python_libei-0.3.0 → python_libei-0.4.0}/src/python_libei.egg-info/SOURCES.txt +0 -0
  21. {python_libei-0.3.0 → python_libei-0.4.0}/src/python_libei.egg-info/dependency_links.txt +0 -0
  22. {python_libei-0.3.0 → python_libei-0.4.0}/src/python_libei.egg-info/requires.txt +0 -0
  23. {python_libei-0.3.0 → python_libei-0.4.0}/src/python_libei.egg-info/top_level.txt +0 -0
  24. {python_libei-0.3.0 → python_libei-0.4.0}/tests/test_cobject.py +0 -0
  25. {python_libei-0.3.0 → python_libei-0.4.0}/tests/test_documentation_shape.py +0 -0
  26. {python_libei-0.3.0 → python_libei-0.4.0}/tests/test_documented_examples.py +0 -0
  27. {python_libei-0.3.0 → python_libei-0.4.0}/tests/test_ei_objects.py +0 -0
  28. {python_libei-0.3.0 → python_libei-0.4.0}/tests/test_eis_objects.py +0 -0
  29. {python_libei-0.3.0 → python_libei-0.4.0}/tests/test_integration_extras.py +0 -0
  30. {python_libei-0.3.0 → python_libei-0.4.0}/tests/test_integration_socketpair.py +0 -0
  31. {python_libei-0.3.0 → python_libei-0.4.0}/tests/test_loader.py +0 -0
  32. {python_libei-0.3.0 → python_libei-0.4.0}/tests/test_oeffis.py +0 -0
@@ -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
@@ -100,7 +100,7 @@ pointer.
100
100
 
101
101
  ## Status
102
102
 
103
- Alpha (`0.3.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
 
@@ -115,7 +115,8 @@ that qualifier covers, concretely:
115
115
  verified by hand, since they need an interactive consent dialog that
116
116
  nothing here can drive automatically — `tests/test_portal.py` covers
117
117
  `libei.portal`'s orchestration (raceless subscribe-before-call, the
118
- `session_handle_token` crash workaround, persist_mode/restore_token)
118
+ `session_handle_token` crash workaround, persist_mode/restore_token, and
119
+ closing the portal session on a negotiation that fails part-way)
119
120
  against a fake D-Bus connection only. `libei.oeffis` was verified by hand
120
121
  on 2026-08-25 (see [Troubleshooting](#troubleshooting)), and
121
122
  `libei.portal` on 2026-09-01 against a real GNOME Wayland session
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-libei"
7
- version = "0.3.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",
@@ -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__"]
@@ -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
@@ -21,6 +21,7 @@ from __future__ import annotations
21
21
 
22
22
  import os
23
23
  import sys
24
+ import time
24
25
  import types
25
26
  from collections.abc import Iterator
26
27
  from typing import Any
@@ -54,7 +55,18 @@ class FakeUnixFDList:
54
55
 
55
56
 
56
57
  class FakeGLibError(Exception):
57
- """Stands in for `GLib.Error`, which every GDBus failure arrives as."""
58
+ """Stands in for `GLib.Error`, which every GDBus failure arrives as.
59
+
60
+ `domain` and `code` are what portal._is_reply_timeout reads to tell
61
+ GDBus's own reply timeout from every other D-Bus failure. They default
62
+ to values that match nothing, so a bare FakeGLibError still stands for
63
+ "some other failure" as it did before.
64
+ """
65
+
66
+ def __init__(self, message: str = "", domain: str = "", code: int = 0) -> None:
67
+ super().__init__(message)
68
+ self.domain = domain
69
+ self.code = code
58
70
 
59
71
 
60
72
  _OPEN_PIPES: list[tuple[int, int]] = []
@@ -97,6 +109,9 @@ class FakeMainLoop:
97
109
  """
98
110
 
99
111
  pending_timeout: Any = None
112
+ # What GLib.timeout_add was asked to wait, so a test can check the
113
+ # Response leg got only the time the call leg left of the deadline.
114
+ pending_timeout_msec: int | None = None
100
115
 
101
116
  def __init__(self) -> None:
102
117
  self._quit = False
@@ -141,6 +156,9 @@ class FakeConnection:
141
156
  self.version = version
142
157
  self.fd_responses = fd_responses or {"ConnectToEIS": _fresh_fd()}
143
158
  self.calls: list[tuple[str, Any]] = []
159
+ # Kept separate from `calls`, whose two-tuple shape several tests
160
+ # compare against exactly: (method, bus name, object path, timeout).
161
+ self.call_targets: list[tuple[str, Any, Any, Any]] = []
144
162
  self._unique_name = unique_name
145
163
  self._subscriptions: dict[str, Any] = {}
146
164
  self._pending_reply: tuple[str, int, dict[str, Any]] | None = None
@@ -168,6 +186,7 @@ class FakeConnection:
168
186
  # `parameters` is None for a method that takes no arguments, which
169
187
  # is exactly what real Gio expects and what Session.Close() sends.
170
188
  self.calls.append((method, None if parameters is None else parameters.value))
189
+ self.call_targets.append((method, bus_name, object_path, timeout))
171
190
  if parameters is None:
172
191
  return FakeReply(())
173
192
  if method == "Get":
@@ -242,6 +261,7 @@ class FakeConnection:
242
261
  cancellable: Any,
243
262
  ) -> tuple[FakeReply, FakeUnixFDList]:
244
263
  self.calls.append((method, parameters.value))
264
+ self.call_targets.append((method, bus_name, object_path, timeout))
245
265
  return FakeReply((0,)), FakeUnixFDList(self.fd_responses[method])
246
266
 
247
267
 
@@ -254,6 +274,9 @@ def install_fake_gi(connection: FakeConnection | None = None) -> Any:
254
274
  Gio.BusType = types.SimpleNamespace(SESSION=1) # type: ignore[attr-defined]
255
275
  Gio.DBusCallFlags = types.SimpleNamespace(NONE=0) # type: ignore[attr-defined]
256
276
  Gio.DBusSignalFlags = types.SimpleNamespace(NONE=0) # type: ignore[attr-defined]
277
+ # 24 is G_IO_ERROR_TIMED_OUT, the code a real call_sync() raises in the
278
+ # g-io-error-quark domain when its reply never comes.
279
+ Gio.IOErrorEnum = types.SimpleNamespace(TIMED_OUT=24) # type: ignore[attr-defined]
257
280
  Gio.bus_get_sync = ( # type: ignore[attr-defined]
258
281
  lambda *a, **kw: connection or FakeConnection()
259
282
  )
@@ -268,8 +291,9 @@ def install_fake_gi(connection: FakeConnection | None = None) -> Any:
268
291
 
269
292
  # timeout_add stashes the callback rather than scheduling it; only the
270
293
  # timeout tests, which never quit() the loop, actually let it fire.
271
- def timeout_add(_ms: int, callback: Any) -> int:
294
+ def timeout_add(ms: int, callback: Any) -> int:
272
295
  FakeMainLoop.pending_timeout = callback
296
+ FakeMainLoop.pending_timeout_msec = ms
273
297
  return 1
274
298
 
275
299
  def source_remove(_id: int) -> None:
@@ -630,3 +654,266 @@ def test_a_failed_session_close_does_not_propagate() -> None:
630
654
  with install_fake_gi(connection):
631
655
  session = portal.RemoteDesktopSession.negotiate(connection=connection)
632
656
  session.close() # must not raise
657
+
658
+
659
+ def _closed_sessions(connection: FakeConnection) -> list[Any]:
660
+ """Object paths ``Session.Close()`` was called on, in order."""
661
+ return [call[2] for call in connection.call_targets if call[0] == "Close"]
662
+
663
+
664
+ class _SilentAfterCreateConnection(FakeConnection):
665
+ """Answers CreateSession, then accepts Start and never responds.
666
+
667
+ Stands in for a portal that dies (or a compositor whose dialog never
668
+ returns) after a session already exists -- the case where a caller is
669
+ left holding nothing at all, since `negotiate` raises rather than
670
+ returning the object whose `close()` would clean up.
671
+ """
672
+
673
+ def call_sync(
674
+ self,
675
+ bus_name: Any,
676
+ object_path: Any,
677
+ interface: Any,
678
+ method: str,
679
+ parameters: FakeVariant | None,
680
+ reply_type: Any,
681
+ flags: Any,
682
+ timeout: Any,
683
+ cancellable: Any,
684
+ ) -> FakeReply:
685
+ if method == "Start":
686
+ self.calls.append((method, parameters.value if parameters else None))
687
+ self.call_targets.append((method, bus_name, object_path, timeout))
688
+ return FakeReply(()) # accepted, but no Response ever fires
689
+ return super().call_sync(
690
+ bus_name,
691
+ object_path,
692
+ interface,
693
+ method,
694
+ parameters,
695
+ reply_type,
696
+ flags,
697
+ timeout,
698
+ cancellable,
699
+ )
700
+
701
+
702
+ def test_a_declined_select_devices_closes_the_portal_session() -> None:
703
+ # CreateSession has already created a session inside xdg-desktop-portal
704
+ # by this point, and nothing else will ever close it: negotiate() raises
705
+ # instead of returning the object whose close() would, and the session
706
+ # outlives the failure on the shared session-bus connection.
707
+ connection = FakeConnection(
708
+ responses={
709
+ "CreateSession": (0, {"session_handle": "/session/1"}),
710
+ "SelectDevices": (1, {}),
711
+ }
712
+ )
713
+ with install_fake_gi(connection):
714
+ with pytest.raises(portal.PortalDeniedError):
715
+ portal.RemoteDesktopSession.negotiate(connection=connection)
716
+ assert _closed_sessions(connection) == ["/session/1"]
717
+
718
+
719
+ def test_a_declined_start_closes_the_portal_session() -> None:
720
+ # The most likely failure of the lot: the user says no to the consent
721
+ # dialog. An approved-then-declined session left open is a grant the
722
+ # portal keeps listing for a process that gave up on it.
723
+ connection = FakeConnection(
724
+ responses={
725
+ "CreateSession": (0, {"session_handle": "/session/1"}),
726
+ "SelectDevices": (0, {}),
727
+ "Start": (1, {}),
728
+ }
729
+ )
730
+ with install_fake_gi(connection):
731
+ with pytest.raises(portal.PortalDeniedError):
732
+ portal.RemoteDesktopSession.negotiate(connection=connection)
733
+ assert _closed_sessions(connection) == ["/session/1"]
734
+
735
+
736
+ def test_a_timed_out_request_closes_the_portal_session() -> None:
737
+ connection = _SilentAfterCreateConnection()
738
+ with install_fake_gi(connection):
739
+ with pytest.raises(portal.PortalTimeoutError) as excinfo:
740
+ portal.RemoteDesktopSession.negotiate(connection=connection, timeout=0.01)
741
+ assert excinfo.value.step == "Start"
742
+ assert _closed_sessions(connection) == ["/session/1"]
743
+
744
+
745
+ def test_a_failed_connect_to_eis_closes_the_portal_session() -> None:
746
+ # The last step, and the one with no Request of its own: a session that
747
+ # was fully approved and then failed to hand back an EIS fd is still a
748
+ # session, and still has to be closed.
749
+ class NoEisConnection(FakeConnection):
750
+ def call_with_unix_fd_list_sync(
751
+ self, *args: Any, **kwargs: Any
752
+ ) -> tuple[FakeReply, FakeUnixFDList]:
753
+ raise FakeGLibError("org.freedesktop.DBus.Error.Failed")
754
+
755
+ connection = NoEisConnection()
756
+ with install_fake_gi(connection):
757
+ with pytest.raises(portal.PortalError, match="D-Bus"):
758
+ portal.RemoteDesktopSession.negotiate(connection=connection)
759
+ assert _closed_sessions(connection) == ["/session/1"]
760
+
761
+
762
+ def test_an_interrupted_consent_dialog_closes_the_portal_session() -> None:
763
+ # Why the cleanup catches BaseException: Start blocks on a human, so
764
+ # Ctrl-C during that wait is a routine way out of negotiate() -- and it
765
+ # strands an approved session exactly as a decline does.
766
+ class InterruptedConnection(FakeConnection):
767
+ def call_sync(
768
+ self,
769
+ bus_name: Any,
770
+ object_path: Any,
771
+ interface: Any,
772
+ method: str,
773
+ parameters: FakeVariant | None,
774
+ reply_type: Any,
775
+ flags: Any,
776
+ timeout: Any,
777
+ cancellable: Any,
778
+ ) -> FakeReply:
779
+ if method == "Start":
780
+ raise KeyboardInterrupt
781
+ return super().call_sync(
782
+ bus_name,
783
+ object_path,
784
+ interface,
785
+ method,
786
+ parameters,
787
+ reply_type,
788
+ flags,
789
+ timeout,
790
+ cancellable,
791
+ )
792
+
793
+ connection = InterruptedConnection()
794
+ with install_fake_gi(connection):
795
+ with pytest.raises(KeyboardInterrupt):
796
+ portal.RemoteDesktopSession.negotiate(connection=connection)
797
+ assert _closed_sessions(connection) == ["/session/1"]
798
+
799
+
800
+ def test_a_successful_negotiation_closes_nothing() -> None:
801
+ # The other half of the cleanup: a session that negotiated fine belongs
802
+ # to the caller until they close it.
803
+ connection = FakeConnection()
804
+ with install_fake_gi(connection):
805
+ session = portal.RemoteDesktopSession.negotiate(connection=connection)
806
+ assert _closed_sessions(connection) == []
807
+ session.close()
808
+ assert _closed_sessions(connection) == ["/session/1"]
809
+
810
+
811
+ def test_a_missing_session_handle_raises_portal_error() -> None:
812
+ # Approved, but with nothing to address the rest of the sequence to.
813
+ # A direct index would raise KeyError straight past `except PortalError`.
814
+ connection = FakeConnection(responses={"CreateSession": (0, {})})
815
+ with install_fake_gi(connection):
816
+ with pytest.raises(portal.PortalError, match="session_handle"):
817
+ portal.RemoteDesktopSession.negotiate(connection=connection)
818
+ # And nothing to close: no handle means no session to end.
819
+ assert _closed_sessions(connection) == []
820
+
821
+
822
+ def test_the_session_is_closed_on_the_bus_name_it_was_negotiated_on() -> None:
823
+ # A session created on an alternate portal name has to be closed on
824
+ # that same name; sending Session.Close() to the default bus name
825
+ # reaches a portal that never heard of this session.
826
+ connection = FakeConnection()
827
+ with install_fake_gi(connection):
828
+ session = portal.RemoteDesktopSession.negotiate(
829
+ connection=connection, busname="org.example.Portal"
830
+ )
831
+ session.close()
832
+ closes = [call for call in connection.call_targets if call[0] == "Close"]
833
+ assert [call[1] for call in closes] == ["org.example.Portal"]
834
+
835
+
836
+ def test_a_failed_negotiation_closes_on_the_bus_name_it_used() -> None:
837
+ connection = FakeConnection(
838
+ responses={
839
+ "CreateSession": (0, {"session_handle": "/session/1"}),
840
+ "SelectDevices": (1, {}),
841
+ }
842
+ )
843
+ with install_fake_gi(connection):
844
+ with pytest.raises(portal.PortalDeniedError):
845
+ portal.RemoteDesktopSession.negotiate(
846
+ connection=connection, busname="org.example.Portal"
847
+ )
848
+ closes = [call for call in connection.call_targets if call[0] == "Close"]
849
+ assert [call[1] for call in closes] == ["org.example.Portal"]
850
+
851
+
852
+ def test_every_negotiation_call_is_bounded_by_the_callers_timeout() -> None:
853
+ # GDBus's -1 is not "no timeout" (that is G_MAXINT) but GIO's own 25s
854
+ # default -- a number this module never chose and a caller cannot see.
855
+ # Every leg carries the timeout the caller asked for instead, including
856
+ # ConnectToEIS, which returns no Request and so has no Response leg of
857
+ # its own for the nested loop to bound.
858
+ connection = FakeConnection()
859
+ with install_fake_gi(connection):
860
+ portal.RemoteDesktopSession.negotiate(connection=connection, timeout=12.0)
861
+ timeouts = {call[0]: call[3] for call in connection.call_targets}
862
+ assert timeouts["Get"] == 12_000
863
+ assert timeouts["ConnectToEIS"] == 12_000
864
+ # The Request-returning legs draw on a shared deadline, so they get at
865
+ # most the full timeout, never more, and never GIO's default.
866
+ for method in ("CreateSession", "SelectDevices", "Start"):
867
+ assert 0 < timeouts[method] <= 12_000
868
+
869
+
870
+ def test_the_response_wait_gets_only_what_the_call_leg_left(
871
+ monkeypatch: pytest.MonkeyPatch,
872
+ ) -> None:
873
+ # One deadline covers the whole round trip. Bounding each leg by the
874
+ # full timeout separately would let a request that spent 4s getting its
875
+ # handle wait another 10s for the Response -- 14 seconds, asked for 10.
876
+ class Clock:
877
+ def __init__(self) -> None:
878
+ self.now = 100.0
879
+
880
+ def __call__(self) -> float:
881
+ return self.now
882
+
883
+ clock = Clock()
884
+
885
+ class SlowCallConnection(_SilentAfterCreateConnection):
886
+ """Takes 4s to answer Start, then never sends a Response."""
887
+
888
+ def call_sync(self, *args: Any, **kwargs: Any) -> FakeReply:
889
+ reply = super().call_sync(*args, **kwargs)
890
+ if args[3] == "Start":
891
+ clock.now += 4.0
892
+ return reply
893
+
894
+ # portal.py reads the stdlib clock directly, so this is the one to
895
+ # replace -- and replacing it beats sleeping for the real 4 seconds.
896
+ monkeypatch.setattr(time, "monotonic", clock)
897
+ connection = SlowCallConnection()
898
+ with install_fake_gi(connection):
899
+ with pytest.raises(portal.PortalTimeoutError):
900
+ portal.RemoteDesktopSession.negotiate(connection=connection, timeout=10.0)
901
+ assert FakeMainLoop.pending_timeout_msec == 6_000
902
+
903
+
904
+ def test_a_dbus_reply_timeout_raises_portal_timeout_error() -> None:
905
+ # GDBus reports its own reply timeout as G_IO_ERROR_TIMED_OUT in the
906
+ # g-io-error-quark domain -- not as an org.freedesktop.DBus.Error.*
907
+ # code -- and it means what the nested loop's timeout means: nobody
908
+ # answered in time. So it raises what a caller catches for that.
909
+ class TimingOutConnection(FakeConnection):
910
+ def call_sync(self, *args: Any, **kwargs: Any) -> FakeReply:
911
+ raise FakeGLibError(
912
+ "Timeout was reached", domain="g-io-error-quark", code=24
913
+ )
914
+
915
+ connection = TimingOutConnection()
916
+ with install_fake_gi(connection):
917
+ with pytest.raises(portal.PortalTimeoutError) as excinfo:
918
+ portal.RemoteDesktopSession.negotiate(connection=connection, timeout=30.0)
919
+ assert excinfo.value.step == "Get"
File without changes
File without changes