python-libei 0.6.1__py3-none-any.whl → 0.8.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
@@ -31,6 +31,6 @@ the full breakdown, including which features need which libei version.
31
31
  Beta: the API is not frozen.
32
32
  """
33
33
 
34
- __version__ = "0.6.1"
34
+ __version__ = "0.8.0"
35
35
 
36
36
  __all__ = ["__version__"]
libei/portal.py CHANGED
@@ -90,6 +90,8 @@ __all__ = [
90
90
  "PortalVersionError",
91
91
  "PortalDeniedError",
92
92
  "PortalTimeoutError",
93
+ "PortalSessionClosedError",
94
+ "PortalProtocolError",
93
95
  "RemoteDesktopSession",
94
96
  "Activation",
95
97
  "InputCaptureSession",
@@ -185,6 +187,67 @@ class PortalDeniedError(PortalError):
185
187
  self.message = message
186
188
 
187
189
 
190
+ class PortalProtocolError(PortalError):
191
+ """A portal signal arrived with a payload that does not match the spec.
192
+
193
+ Every signal :func:`_wait_for_signal` waits on is specified as
194
+ ``(o session_handle, a{sv} options)``, and two kinds of mismatch used
195
+ to fail in two different, equally unhelpful ways. The *shape* being
196
+ wrong -- an empty tuple, a non-string handle, a wrong-length tuple, a
197
+ non-dict options -- either raised ``IndexError`` *inside* the GLib
198
+ signal callback, where PyGI prints the traceback to stderr and
199
+ swallows it so the wait hung invisibly (unboundedly, with
200
+ ``timeout=None``), or slipped through to the caller and crashed it with
201
+ ``ValueError``/``AttributeError``. A *value* inside a correctly-shaped
202
+ options being wrong -- an ``activation_id`` that is not an integer, a
203
+ ``cursor_position`` that is not two numbers -- crashed the caller later
204
+ still, doing arithmetic on it. Both now end the wait with this error.
205
+
206
+ ``detail`` distinguishes the two: ``None`` for a shape fault (the
207
+ default message names the expected shape), a phrase for a value fault
208
+ (naming the field). ``payload`` is whatever best shows the fault -- the
209
+ whole signal tuple for a shape fault, the options dict for a value one.
210
+ """
211
+
212
+ def __init__(self, step: str, payload: object, detail: str | None = None) -> None:
213
+ """Name the signal, what arrived, and -- for a value fault -- which."""
214
+ reason = detail or (
215
+ "the portal spec gives it the shape (session_handle, options dict)"
216
+ )
217
+ super().__init__(
218
+ f"{step} arrived with a malformed payload: {reason}, got {payload!r}"
219
+ )
220
+ # `step` mirrors the attribute every sibling portal error raised
221
+ # from the same `_wait_for_signal` call site carries
222
+ # (PortalTimeoutError, PortalSessionClosedError), so a caller that
223
+ # catches PortalError and reads `.step` works across all of them.
224
+ self.step = step
225
+ self.payload = payload
226
+ self.detail = detail
227
+
228
+
229
+ class PortalSessionClosedError(PortalError):
230
+ """The portal session ended while something was still waiting on it.
231
+
232
+ The ``org.freedesktop.portal.Session`` interface emits ``Closed`` when
233
+ the compositor or the portal ends a session from its side -- a
234
+ compositor restart, the portal process dying, a session revoked from a
235
+ settings panel. Distinct from :class:`PortalTimeoutError` (the session
236
+ is fine, the signal just has not come) and from
237
+ :class:`PortalDeniedError` (a request was answered, negatively): here
238
+ there is no session left to answer anything, so the wait that raised
239
+ this can never be satisfied by waiting longer.
240
+ """
241
+
242
+ def __init__(self, step: str) -> None:
243
+ """Name what was being waited for when the session went away."""
244
+ super().__init__(
245
+ f"the portal session was closed while waiting for {step}; "
246
+ "the compositor or portal ended it from its side"
247
+ )
248
+ self.step = step
249
+
250
+
188
251
  def _gio() -> tuple[Any, Any] | None:
189
252
  """Import Gio and GLib, or return None.
190
253
 
@@ -999,6 +1062,98 @@ def _create_input_capture_session_v1(
999
1062
  return session_handle
1000
1063
 
1001
1064
 
1065
+ def _classify_signal_payload(
1066
+ args: tuple[Any, ...], session_handle: str, signal: str
1067
+ ) -> str:
1068
+ """How `_wait_for_signal` should treat one unpacked signal payload.
1069
+
1070
+ ``"take"`` -- it is this session's, correctly shaped, record it.
1071
+ ``"drop"`` -- another session's; keep waiting (logged, since a dropped
1072
+ signal is otherwise indistinguishable from one that never fired).
1073
+ ``"malformed"`` -- not the ``(o session_handle, a{sv} options)`` shape
1074
+ the spec gives every signal here.
1075
+
1076
+ Two shape checks with the session filter between them, so how a
1077
+ malformed payload is treated depends on how it is broken. A payload
1078
+ whose *options* are the wrong shape (the second check) is reached only
1079
+ after the filter has matched its handle, so another session's is already
1080
+ dropped before that check runs -- it is raised only for this session. A
1081
+ payload with no first element, or one that is not a string, cannot be
1082
+ matched to any session at all: there is no handle to compare it with, so
1083
+ it is malformed for every possible waiter and is raised here whichever
1084
+ session it might have been meant for -- the filter cannot run before it,
1085
+ only after. In the single-session process this almost always is, the
1086
+ distinction never arises.
1087
+ """
1088
+ if not args or not isinstance(args[0], str):
1089
+ return "malformed"
1090
+ if args[0] != session_handle:
1091
+ logger.debug(
1092
+ "ignoring %s for session %s while waiting for %s",
1093
+ signal,
1094
+ args[0],
1095
+ session_handle,
1096
+ )
1097
+ return "drop"
1098
+ if len(args) != 2 or not isinstance(args[1], dict):
1099
+ return "malformed"
1100
+ return "take"
1101
+
1102
+
1103
+ def _deactivation_id(options: dict[str, Any], signal: str) -> int:
1104
+ """The ``activation_id`` from Deactivated's options, as the spec's ``u``.
1105
+
1106
+ ``_wait_for_signal`` checks the payload's *shape* -- that options is a
1107
+ dict -- but not the types of the values in it. ``activation_id`` is a
1108
+ ``u``, and a non-conforming portal that put something else there would
1109
+ crash the caller with ``ValueError``/``TypeError``/``OverflowError``
1110
+ (the last from ``int(float('inf'))``) past the shape check. Converting
1111
+ it here turns that into the same typed :class:`PortalProtocolError` the
1112
+ shape check raises, so a caller that guards against malformed signals
1113
+ catches one error, not two.
1114
+ """
1115
+ try:
1116
+ return int(options.get("activation_id", 0))
1117
+ except (ValueError, TypeError, OverflowError) as exc:
1118
+ raise PortalProtocolError(
1119
+ signal, options, "activation_id is not an integer"
1120
+ ) from exc
1121
+
1122
+
1123
+ def _activation_from_options(options: dict[str, Any], signal: str) -> Activation:
1124
+ """Build an :class:`Activation` from Activated's options, spec-typed.
1125
+
1126
+ The companion to the shape check in ``_wait_for_signal`` for the value
1127
+ level: every field the portal spec types is converted here under one
1128
+ guard, so a non-conforming value in any of them -- an ``activation_id``
1129
+ that is not a ``u``, a ``cursor_position`` that is not two ``d``s, a
1130
+ ``barrier_id`` that is not a ``u`` -- becomes the same typed
1131
+ :class:`PortalProtocolError` rather than a crash in the caller later,
1132
+ doing arithmetic on a string. One guard rather than three means the
1133
+ next field added to the signal is validated by construction, not by a
1134
+ fourth hand-rolled check nobody remembers to write.
1135
+ """
1136
+ try:
1137
+ activation_id = int(options.get("activation_id", 0))
1138
+ cursor = options.get("cursor_position")
1139
+ if cursor is None:
1140
+ cursor_position = None
1141
+ else:
1142
+ cx, cy = cursor # (dd): exactly two, or this unpacking raises
1143
+ cursor_position = (float(cx), float(cy))
1144
+ barrier = options.get("barrier_id")
1145
+ barrier_id = None if barrier is None else int(barrier)
1146
+ except (ValueError, TypeError, OverflowError) as exc:
1147
+ raise PortalProtocolError(
1148
+ signal, options, "an option value is not the type the spec gives it"
1149
+ ) from exc
1150
+ return Activation(
1151
+ activation_id=activation_id,
1152
+ cursor_position=cursor_position,
1153
+ barrier_id=barrier_id,
1154
+ )
1155
+
1156
+
1002
1157
  def _wait_for_signal(
1003
1158
  connection: Any,
1004
1159
  Gio: Any,
@@ -1034,10 +1189,60 @@ def _wait_for_signal(
1034
1189
  D-Bus specification restricts plain ``arg0=`` match rules to arguments
1035
1190
  of type STRING, and this one is an OBJECT_PATH, which is the same shape
1036
1191
  of silent non-delivery all over again.
1192
+
1193
+ A payload that does not match the spec's ``(o session_handle, a{sv}
1194
+ options)`` shape raises :class:`PortalProtocolError` rather than being
1195
+ unpacked on faith -- see that class for why the alternative was not an
1196
+ exception but a hang.
1037
1197
  """
1038
1198
  loop = GLib.MainLoop()
1039
1199
  result: dict[str, Any] = {}
1200
+ malformed: dict[str, Any] = {}
1040
1201
  timed_out = False
1202
+ # Distinct from `timed_out`: whether `on_timeout` ran at all, either
1203
+ # path. `on_timeout` returns False whichever branch it takes, and False
1204
+ # is what makes GLib deregister the source -- so the finally block must
1205
+ # key its "already removed?" check on "did on_timeout fire", not on "did
1206
+ # the wait time out". When a signal and the deadline land in the same
1207
+ # poll, on_timeout takes the `_settled()` early return (leaving
1208
+ # `timed_out` False) yet its False return still removed the source;
1209
+ # keying on `timed_out` there would double-remove it and trip the very
1210
+ # "Source ID N was not found" critical the guard exists to avoid.
1211
+ timeout_fired = False
1212
+ session_closed = False
1213
+
1214
+ def _settled() -> bool:
1215
+ """Whether some terminal outcome has already been recorded.
1216
+
1217
+ GLib dispatches every source ready in one main-context iteration,
1218
+ regardless of an earlier ``loop.quit()``, so two outcomes -- a
1219
+ signal and the timeout, a signal and Closed -- can both fire in the
1220
+ same poll. Every callback checks this first so the *first* recorded
1221
+ outcome wins and the post-loop raise order is never handed a second
1222
+ one to misreport (a delivered activation dropped for a timeout, a
1223
+ protocol fault overwritten by session-closed).
1224
+ """
1225
+ return bool(result) or bool(malformed) or timed_out or session_closed
1226
+
1227
+ def on_closed(*_args: Any) -> None:
1228
+ """End the wait: a closed session can never deliver the signal.
1229
+
1230
+ ``Closed`` is emitted on the **session object's own path** (unlike
1231
+ ``Activated``/``Deactivated``, which fire on the portal object --
1232
+ see above), so the subscription below uses ``session_handle`` as
1233
+ the path and needs no payload filtering: any ``Closed`` there is
1234
+ this session's. Without this subscription, a wait with
1235
+ ``timeout=None`` on a session the compositor has ended -- a
1236
+ compositor restart, the portal dying, capture revoked from a
1237
+ settings panel -- blocked forever, indistinguishable from a signal
1238
+ that merely had not fired yet.
1239
+ """
1240
+ nonlocal session_closed
1241
+ if _settled():
1242
+ return
1243
+ logger.debug("session %s closed while waiting for %s", session_handle, signal)
1244
+ session_closed = True
1245
+ loop.quit()
1041
1246
 
1042
1247
  def on_signal(
1043
1248
  _conn: Any,
@@ -1049,21 +1254,22 @@ def _wait_for_signal(
1049
1254
  *_a: Any,
1050
1255
  ) -> None:
1051
1256
  """Record the first matching signal; anything else is logged and dropped."""
1052
- if result: # a subscription that outlives its own wait can fire twice
1257
+ if _settled(): # a subscription outliving its wait fires twice
1053
1258
  return
1054
1259
  args = params.unpack()
1055
- if args[0] != session_handle:
1056
- # Logged, not silently dropped: this filter can hide a signal
1057
- # that *did* arrive just as effectively as the wrong-path
1058
- # subscription above did, and the two are indistinguishable
1059
- # from the outside -- both look exactly like a compositor that
1060
- # never fired. One debug line is what tells them apart.
1061
- logger.debug(
1062
- "ignoring %s for session %s while waiting for %s",
1063
- signal,
1064
- args[0],
1065
- session_handle,
1066
- )
1260
+ # The verdict is computed by `_classify_signal_payload`, but ACTED
1261
+ # on here and the error raised only after the loop, because this
1262
+ # runs inside a GLib callback: PyGI prints an exception raised here
1263
+ # to stderr and swallows it, so a bare raise on a malformed payload
1264
+ # would leave the loop running -- a timeout at best, an unbounded
1265
+ # hang with timeout=None, both indistinguishable from a compositor
1266
+ # that never fired.
1267
+ verdict = _classify_signal_payload(args, session_handle, signal)
1268
+ if verdict == "drop":
1269
+ return
1270
+ if verdict == "malformed":
1271
+ malformed["args"] = args
1272
+ loop.quit()
1067
1273
  return
1068
1274
  logger.debug("received %s for session %s", signal, session_handle)
1069
1275
  result["args"] = args
@@ -1071,7 +1277,16 @@ def _wait_for_signal(
1071
1277
 
1072
1278
  def on_timeout() -> bool:
1073
1279
  """Stop the loop and record that it timed out rather than finished."""
1074
- nonlocal timed_out
1280
+ nonlocal timed_out, timeout_fired
1281
+ # Set first, before the early return: GLib removes the source the
1282
+ # moment this returns False (which both paths do), so the finally
1283
+ # block keys "already removed" on this, not on `timed_out`.
1284
+ timeout_fired = True
1285
+ if _settled():
1286
+ # A signal delivered in the same poll as the deadline has
1287
+ # already recorded its outcome; the timeout must not overwrite
1288
+ # a real activation with PortalTimeoutError.
1289
+ return False
1075
1290
  timed_out = True
1076
1291
  loop.quit()
1077
1292
  return False
@@ -1086,6 +1301,16 @@ def _wait_for_signal(
1086
1301
  on_signal,
1087
1302
  None,
1088
1303
  )
1304
+ closed_subscription = connection.signal_subscribe(
1305
+ busname,
1306
+ _SESSION_INTERFACE,
1307
+ "Closed",
1308
+ session_handle,
1309
+ None,
1310
+ Gio.DBusSignalFlags.NONE,
1311
+ on_closed,
1312
+ None,
1313
+ )
1089
1314
  logger.debug(
1090
1315
  "waiting up to %s for %s.%s on %s for session %s",
1091
1316
  "forever" if timeout is None else f"{timeout:g}s",
@@ -1095,7 +1320,7 @@ def _wait_for_signal(
1095
1320
  session_handle,
1096
1321
  )
1097
1322
  try:
1098
- if not result:
1323
+ if not result and not session_closed and not malformed:
1099
1324
  timeout_source = None
1100
1325
  if timeout is not None:
1101
1326
  timeout_source = GLib.timeout_add(int(timeout * 1000), on_timeout)
@@ -1111,10 +1336,41 @@ def _wait_for_signal(
1111
1336
  # every real timeout this session hit, never against
1112
1337
  # test_portal.py's fake GLib.source_remove, which just
1113
1338
  # clears pending_timeout with no complaint either way.
1114
- if timeout_source is not None and not timed_out:
1339
+ # Keyed on `timeout_fired` (did on_timeout run), not
1340
+ # `timed_out` (did the wait time out): a signal landing in
1341
+ # the same poll as the deadline runs on_timeout down its
1342
+ # `_settled` early return, which removes the source yet
1343
+ # leaves `timed_out` False.
1344
+ if timeout_source is not None and not timeout_fired:
1115
1345
  GLib.source_remove(timeout_source)
1116
1346
  finally:
1117
1347
  connection.signal_unsubscribe(subscription)
1348
+ connection.signal_unsubscribe(closed_subscription)
1349
+ return _signal_outcome(
1350
+ signal, timeout, result, malformed, timed_out, session_closed
1351
+ )
1352
+
1353
+
1354
+ def _signal_outcome(
1355
+ signal: str,
1356
+ timeout: float | None,
1357
+ result: dict[str, Any],
1358
+ malformed: dict[str, Any],
1359
+ timed_out: bool,
1360
+ session_closed: bool,
1361
+ ) -> tuple[Any, ...]:
1362
+ """Raise for the terminal outcome `_wait_for_signal` recorded, or return it.
1363
+
1364
+ Order is load-bearing and matches the callbacks' `_settled` guard:
1365
+ whichever outcome was recorded first is the only one set, so this order
1366
+ only ever decides between genuinely simultaneous dispatches, where a
1367
+ session that has closed or a payload that was malformed is the more
1368
+ useful answer than "timed out".
1369
+ """
1370
+ if session_closed:
1371
+ raise PortalSessionClosedError(signal)
1372
+ if malformed:
1373
+ raise PortalProtocolError(signal, malformed["args"])
1118
1374
  if timed_out:
1119
1375
  # timed_out is only ever set inside on_timeout, itself only ever
1120
1376
  # registered when timeout is not None -- so this always holds, but
@@ -1441,12 +1697,7 @@ class InputCaptureSession:
1441
1697
  self.session_handle,
1442
1698
  timeout,
1443
1699
  )
1444
- cursor = options.get("cursor_position")
1445
- return Activation(
1446
- activation_id=int(options.get("activation_id", 0)),
1447
- cursor_position=tuple(cursor) if cursor is not None else None,
1448
- barrier_id=options.get("barrier_id"),
1449
- )
1700
+ return _activation_from_options(options, "Activated")
1450
1701
 
1451
1702
  def wait_for_deactivation(self, timeout: float | None = None) -> int:
1452
1703
  """Block until the current capture ends, returning its activation_id."""
@@ -1464,7 +1715,7 @@ class InputCaptureSession:
1464
1715
  self.session_handle,
1465
1716
  timeout,
1466
1717
  )
1467
- return int(options.get("activation_id", 0))
1718
+ return _deactivation_id(options, "Deactivated")
1468
1719
 
1469
1720
  def close(self) -> None:
1470
1721
  """End the portal session, and close the EIS fd if unclaimed.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-libei
3
- Version: 0.6.1
3
+ Version: 0.8.0
4
4
  Summary: Inject, receive and capture input on Wayland from Python: ctypes bindings for libei, libeis and liboeffis, plus the RemoteDesktop and InputCapture portals
5
5
  Author: Dennis K. Paulsen
6
6
  License-Expression: MIT
@@ -40,6 +40,7 @@ Dynamic: license-file
40
40
 
41
41
  # python-libei
42
42
 
43
+ [![Contributing](https://img.shields.io/badge/contributions-welcome-brightgreen.svg)](https://github.com/ctrondlp/python-libei/blob/main/CONTRIBUTING.md)
43
44
  [![CI](https://github.com/ctrondlp/python-libei/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/ctrondlp/python-libei/actions/workflows/ci.yml)
44
45
  [![PyPI](https://img.shields.io/pypi/v/python-libei)](https://pypi.org/project/python-libei/)
45
46
  [![License](https://img.shields.io/pypi/l/python-libei)](https://github.com/ctrondlp/python-libei/blob/main/LICENSE)
@@ -174,7 +175,7 @@ and why, is in
174
175
 
175
176
  ## Status
176
177
 
177
- Beta (`0.6.1`), published on [PyPI](https://pypi.org/project/python-libei/)
178
+ Beta (`0.8.0`), published on [PyPI](https://pypi.org/project/python-libei/)
178
179
  since `0.1.0`, and **the API is not frozen** — expect renames before 1.0.
179
180
 
180
181
  The injection path is exercised end to end against the real libraries by the
@@ -1,17 +1,17 @@
1
- libei/__init__.py,sha256=OP-aoBP8bXNhL8q3gXxVL4FtWLhVyaBlmoQEInBdhp4,1858
1
+ libei/__init__.py,sha256=8k-acXmeIea963zVksZW5Ymby8ZYL_aO6B4o7g4_Y9w,1858
2
2
  libei/_cobject.py,sha256=XjbUNJpZ87jAyPWh6dmRslk6cMy831oMRFhWmfjLDFo,13344
3
3
  libei/ei.py,sha256=W4akKc_BNVfL0FroSXLGD1Z_L3OmYjZT5QDHNnEtMP8,49367
4
4
  libei/eis.py,sha256=X_umAaAAbAMC9d2RdnMReeJZI61WsnKz3cySY5YrGr0,45841
5
5
  libei/oeffis.py,sha256=gJHR90s9VhQrgkjWUMDx-lxZ8qKxkttnqbx_xo-7oxs,10035
6
- libei/portal.py,sha256=XC_FsKueIpcmAPZ4TDXJgFhucJNeMEO2byTJHG3GMmI,69756
6
+ libei/portal.py,sha256=FdtVdUUKrq2GOcDBtunLCISw7RYO4Mwyk3ci26OH330,81621
7
7
  libei/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
8
  libei/_capi/__init__.py,sha256=raISSWSV9hAcE92DcLCz-vUb1IPcsmxRZaS-iAoQJBE,211
9
9
  libei/_capi/libei.py,sha256=qsccHa_komCxtvPYi6AobszhFbEG2qA8QYJJVr8SaGM,11356
10
10
  libei/_capi/libeis.py,sha256=C_Uh7DwNncFC8A4wuMhviAj5C_TUmKHtLJYq0Qhhz1E,13142
11
11
  libei/_capi/liboeffis.py,sha256=V8jdmJm3qOQUzzNiCAXt4Jr2JY11Y4-w6NIfIxbxGI4,1218
12
12
  libei/_capi/loader.py,sha256=xW67uk35ckgeFaheDGrHC05ZM5w_cxBGvX5lG_GUSu8,6625
13
- python_libei-0.6.1.dist-info/licenses/LICENSE,sha256=l6xbMU6Y-JZDzmBciBj-J5t6h6jNg_Bcyp4bHxDepqU,1074
14
- python_libei-0.6.1.dist-info/METADATA,sha256=nV9GTh7ArlrDPXyPTt4fGHJiPP22J9bmA2e8v-mDgp0,22728
15
- python_libei-0.6.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
16
- python_libei-0.6.1.dist-info/top_level.txt,sha256=_DQXzGjDsUBENI_cNkiOxPB4xi8coCbQS1lq18FMudQ,6
17
- python_libei-0.6.1.dist-info/RECORD,,
13
+ python_libei-0.8.0.dist-info/licenses/LICENSE,sha256=l6xbMU6Y-JZDzmBciBj-J5t6h6jNg_Bcyp4bHxDepqU,1074
14
+ python_libei-0.8.0.dist-info/METADATA,sha256=SNey10DqR4IKZ_T_XCKmafkR0ioMfxHbdbaSl9IWG-c,22882
15
+ python_libei-0.8.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
16
+ python_libei-0.8.0.dist-info/top_level.txt,sha256=_DQXzGjDsUBENI_cNkiOxPB4xi8coCbQS1lq18FMudQ,6
17
+ python_libei-0.8.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (84.0.0)
2
+ Generator: setuptools (84.2.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5