python-libei 0.7.0__tar.gz → 0.8.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 (37) hide show
  1. {python_libei-0.7.0/src/python_libei.egg-info → python_libei-0.8.0}/PKG-INFO +2 -2
  2. {python_libei-0.7.0 → python_libei-0.8.0}/README.md +1 -1
  3. {python_libei-0.7.0 → python_libei-0.8.0}/pyproject.toml +1 -1
  4. {python_libei-0.7.0 → python_libei-0.8.0}/src/libei/__init__.py +1 -1
  5. {python_libei-0.7.0 → python_libei-0.8.0}/src/libei/portal.py +219 -24
  6. {python_libei-0.7.0 → python_libei-0.8.0/src/python_libei.egg-info}/PKG-INFO +2 -2
  7. {python_libei-0.7.0 → python_libei-0.8.0}/tests/test_inputcapture.py +217 -0
  8. {python_libei-0.7.0 → python_libei-0.8.0}/tests/test_portal.py +11 -0
  9. {python_libei-0.7.0 → python_libei-0.8.0}/LICENSE +0 -0
  10. {python_libei-0.7.0 → python_libei-0.8.0}/setup.cfg +0 -0
  11. {python_libei-0.7.0 → python_libei-0.8.0}/src/libei/_capi/__init__.py +0 -0
  12. {python_libei-0.7.0 → python_libei-0.8.0}/src/libei/_capi/libei.py +0 -0
  13. {python_libei-0.7.0 → python_libei-0.8.0}/src/libei/_capi/libeis.py +0 -0
  14. {python_libei-0.7.0 → python_libei-0.8.0}/src/libei/_capi/liboeffis.py +0 -0
  15. {python_libei-0.7.0 → python_libei-0.8.0}/src/libei/_capi/loader.py +0 -0
  16. {python_libei-0.7.0 → python_libei-0.8.0}/src/libei/_cobject.py +0 -0
  17. {python_libei-0.7.0 → python_libei-0.8.0}/src/libei/ei.py +0 -0
  18. {python_libei-0.7.0 → python_libei-0.8.0}/src/libei/eis.py +0 -0
  19. {python_libei-0.7.0 → python_libei-0.8.0}/src/libei/oeffis.py +0 -0
  20. {python_libei-0.7.0 → python_libei-0.8.0}/src/libei/py.typed +0 -0
  21. {python_libei-0.7.0 → python_libei-0.8.0}/src/python_libei.egg-info/SOURCES.txt +0 -0
  22. {python_libei-0.7.0 → python_libei-0.8.0}/src/python_libei.egg-info/dependency_links.txt +0 -0
  23. {python_libei-0.7.0 → python_libei-0.8.0}/src/python_libei.egg-info/requires.txt +0 -0
  24. {python_libei-0.7.0 → python_libei-0.8.0}/src/python_libei.egg-info/top_level.txt +0 -0
  25. {python_libei-0.7.0 → python_libei-0.8.0}/tests/test_abi.py +0 -0
  26. {python_libei-0.7.0 → python_libei-0.8.0}/tests/test_cobject.py +0 -0
  27. {python_libei-0.7.0 → python_libei-0.8.0}/tests/test_documentation_shape.py +0 -0
  28. {python_libei-0.7.0 → python_libei-0.8.0}/tests/test_documented_examples.py +0 -0
  29. {python_libei-0.7.0 → python_libei-0.8.0}/tests/test_ei_objects.py +0 -0
  30. {python_libei-0.7.0 → python_libei-0.8.0}/tests/test_eis_objects.py +0 -0
  31. {python_libei-0.7.0 → python_libei-0.8.0}/tests/test_event_accessors.py +0 -0
  32. {python_libei-0.7.0 → python_libei-0.8.0}/tests/test_integration_extras.py +0 -0
  33. {python_libei-0.7.0 → python_libei-0.8.0}/tests/test_integration_frame.py +0 -0
  34. {python_libei-0.7.0 → python_libei-0.8.0}/tests/test_integration_lifetime.py +0 -0
  35. {python_libei-0.7.0 → python_libei-0.8.0}/tests/test_integration_socketpair.py +0 -0
  36. {python_libei-0.7.0 → python_libei-0.8.0}/tests/test_loader.py +0 -0
  37. {python_libei-0.7.0 → python_libei-0.8.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.7.0
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
@@ -175,7 +175,7 @@ and why, is in
175
175
 
176
176
  ## Status
177
177
 
178
- Beta (`0.7.0`), published on [PyPI](https://pypi.org/project/python-libei/)
178
+ Beta (`0.8.0`), published on [PyPI](https://pypi.org/project/python-libei/)
179
179
  since `0.1.0`, and **the API is not frozen** — expect renames before 1.0.
180
180
 
181
181
  The injection path is exercised end to end against the real libraries by the
@@ -135,7 +135,7 @@ and why, is in
135
135
 
136
136
  ## Status
137
137
 
138
- Beta (`0.7.0`), published on [PyPI](https://pypi.org/project/python-libei/)
138
+ Beta (`0.8.0`), published on [PyPI](https://pypi.org/project/python-libei/)
139
139
  since `0.1.0`, and **the API is not frozen** — expect renames before 1.0.
140
140
 
141
141
  The injection path is exercised end to end against the real libraries by the
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-libei"
7
- version = "0.7.0"
7
+ version = "0.8.0"
8
8
  description = "Inject, receive and capture input on Wayland from Python: ctypes bindings for libei, libeis and liboeffis, plus the RemoteDesktop and InputCapture portals"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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.7.0"
34
+ __version__ = "0.8.0"
35
35
 
36
36
  __all__ = ["__version__"]
@@ -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,45 @@ 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
+
188
229
  class PortalSessionClosedError(PortalError):
189
230
  """The portal session ended while something was still waiting on it.
190
231
 
@@ -1021,6 +1062,98 @@ def _create_input_capture_session_v1(
1021
1062
  return session_handle
1022
1063
 
1023
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
+
1024
1157
  def _wait_for_signal(
1025
1158
  connection: Any,
1026
1159
  Gio: Any,
@@ -1056,12 +1189,41 @@ def _wait_for_signal(
1056
1189
  D-Bus specification restricts plain ``arg0=`` match rules to arguments
1057
1190
  of type STRING, and this one is an OBJECT_PATH, which is the same shape
1058
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.
1059
1197
  """
1060
1198
  loop = GLib.MainLoop()
1061
1199
  result: dict[str, Any] = {}
1200
+ malformed: dict[str, Any] = {}
1062
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
1063
1212
  session_closed = False
1064
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
+
1065
1227
  def on_closed(*_args: Any) -> None:
1066
1228
  """End the wait: a closed session can never deliver the signal.
1067
1229
 
@@ -1076,7 +1238,7 @@ def _wait_for_signal(
1076
1238
  that merely had not fired yet.
1077
1239
  """
1078
1240
  nonlocal session_closed
1079
- if result:
1241
+ if _settled():
1080
1242
  return
1081
1243
  logger.debug("session %s closed while waiting for %s", session_handle, signal)
1082
1244
  session_closed = True
@@ -1092,21 +1254,22 @@ def _wait_for_signal(
1092
1254
  *_a: Any,
1093
1255
  ) -> None:
1094
1256
  """Record the first matching signal; anything else is logged and dropped."""
1095
- if result: # a subscription that outlives its own wait can fire twice
1257
+ if _settled(): # a subscription outliving its wait fires twice
1096
1258
  return
1097
1259
  args = params.unpack()
1098
- if args[0] != session_handle:
1099
- # Logged, not silently dropped: this filter can hide a signal
1100
- # that *did* arrive just as effectively as the wrong-path
1101
- # subscription above did, and the two are indistinguishable
1102
- # from the outside -- both look exactly like a compositor that
1103
- # never fired. One debug line is what tells them apart.
1104
- logger.debug(
1105
- "ignoring %s for session %s while waiting for %s",
1106
- signal,
1107
- args[0],
1108
- session_handle,
1109
- )
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()
1110
1273
  return
1111
1274
  logger.debug("received %s for session %s", signal, session_handle)
1112
1275
  result["args"] = args
@@ -1114,7 +1277,16 @@ def _wait_for_signal(
1114
1277
 
1115
1278
  def on_timeout() -> bool:
1116
1279
  """Stop the loop and record that it timed out rather than finished."""
1117
- 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
1118
1290
  timed_out = True
1119
1291
  loop.quit()
1120
1292
  return False
@@ -1148,7 +1320,7 @@ def _wait_for_signal(
1148
1320
  session_handle,
1149
1321
  )
1150
1322
  try:
1151
- if not result and not session_closed:
1323
+ if not result and not session_closed and not malformed:
1152
1324
  timeout_source = None
1153
1325
  if timeout is not None:
1154
1326
  timeout_source = GLib.timeout_add(int(timeout * 1000), on_timeout)
@@ -1164,13 +1336,41 @@ def _wait_for_signal(
1164
1336
  # every real timeout this session hit, never against
1165
1337
  # test_portal.py's fake GLib.source_remove, which just
1166
1338
  # clears pending_timeout with no complaint either way.
1167
- 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:
1168
1345
  GLib.source_remove(timeout_source)
1169
1346
  finally:
1170
1347
  connection.signal_unsubscribe(subscription)
1171
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
+ """
1172
1370
  if session_closed:
1173
1371
  raise PortalSessionClosedError(signal)
1372
+ if malformed:
1373
+ raise PortalProtocolError(signal, malformed["args"])
1174
1374
  if timed_out:
1175
1375
  # timed_out is only ever set inside on_timeout, itself only ever
1176
1376
  # registered when timeout is not None -- so this always holds, but
@@ -1497,12 +1697,7 @@ class InputCaptureSession:
1497
1697
  self.session_handle,
1498
1698
  timeout,
1499
1699
  )
1500
- cursor = options.get("cursor_position")
1501
- return Activation(
1502
- activation_id=int(options.get("activation_id", 0)),
1503
- cursor_position=tuple(cursor) if cursor is not None else None,
1504
- barrier_id=options.get("barrier_id"),
1505
- )
1700
+ return _activation_from_options(options, "Activated")
1506
1701
 
1507
1702
  def wait_for_deactivation(self, timeout: float | None = None) -> int:
1508
1703
  """Block until the current capture ends, returning its activation_id."""
@@ -1520,7 +1715,7 @@ class InputCaptureSession:
1520
1715
  self.session_handle,
1521
1716
  timeout,
1522
1717
  )
1523
- return int(options.get("activation_id", 0))
1718
+ return _deactivation_id(options, "Deactivated")
1524
1719
 
1525
1720
  def close(self) -> None:
1526
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.7.0
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
@@ -175,7 +175,7 @@ and why, is in
175
175
 
176
176
  ## Status
177
177
 
178
- Beta (`0.7.0`), published on [PyPI](https://pypi.org/project/python-libei/)
178
+ Beta (`0.8.0`), published on [PyPI](https://pypi.org/project/python-libei/)
179
179
  since `0.1.0`, and **the API is not frozen** — expect renames before 1.0.
180
180
 
181
181
  The injection path is exercised end to end against the real libraries by the
@@ -32,6 +32,7 @@ import pytest
32
32
  from libei import portal
33
33
  from test_portal import (
34
34
  FakeGLibError,
35
+ FakeMainLoop,
35
36
  FakeReply,
36
37
  FakeUnixFDList,
37
38
  FakeVariant,
@@ -84,6 +85,8 @@ class FakeInputCaptureConnection:
84
85
  pending_deactivated: dict[str, Any] | None = None,
85
86
  signal_session_handle: str | None = None,
86
87
  pending_closed: bool = False,
88
+ pending_activated_raw: tuple[Any, ...] | None = None,
89
+ pending_deactivated_raw: tuple[Any, ...] | None = None,
87
90
  ) -> None:
88
91
  self.session_handle = session_handle
89
92
  self.signal_session_handle = signal_session_handle or session_handle
@@ -107,6 +110,12 @@ class FakeInputCaptureConnection:
107
110
  self._pending_activated = pending_activated
108
111
  self._pending_deactivated = pending_deactivated
109
112
  self._pending_closed = pending_closed
113
+ # Delivered VERBATIM as the signal's whole parameter tuple, unlike
114
+ # pending_activated, which is an options dict the fake wraps in the
115
+ # correct (session_handle, options) shape -- this is how a test
116
+ # hands the waiter a payload a spec-conforming portal cannot send.
117
+ self._pending_activated_raw = pending_activated_raw
118
+ self._pending_deactivated_raw = pending_deactivated_raw
110
119
  # A permanent log, unlike _subscriptions: signal_unsubscribe removes
111
120
  # an entry from that (correctly -- see its own docstring), so a test
112
121
  # checking *what was subscribed to* after the fact needs a record
@@ -178,10 +187,20 @@ class FakeInputCaptureConnection:
178
187
  self._pending_reply = None
179
188
  _path, code, results = pending
180
189
  callback(None, None, path, None, "Response", FakeReply((code, results)))
190
+ elif signal == "Activated" and self._pending_activated_raw is not None:
191
+ raw = self._pending_activated_raw
192
+ self._pending_activated_raw = None
193
+ callback(None, None, portal._OBJECT_PATH, None, "Activated", FakeReply(raw))
181
194
  elif signal == "Activated" and self._pending_activated is not None:
182
195
  payload = self._pending_activated
183
196
  self._pending_activated = None
184
197
  self._emit_session_signal(callback, "Activated", payload)
198
+ elif signal == "Deactivated" and self._pending_deactivated_raw is not None:
199
+ raw = self._pending_deactivated_raw
200
+ self._pending_deactivated_raw = None
201
+ callback(
202
+ None, None, portal._OBJECT_PATH, None, "Deactivated", FakeReply(raw)
203
+ )
185
204
  elif signal == "Deactivated" and self._pending_deactivated is not None:
186
205
  payload = self._pending_deactivated
187
206
  self._pending_deactivated = None
@@ -566,6 +585,166 @@ def test_wait_for_activation_tolerates_a_missing_barrier_id() -> None:
566
585
  assert activation.barrier_id is None
567
586
 
568
587
 
588
+ def test_a_malformed_activated_payload_raises_a_typed_error() -> None:
589
+ # An empty payload cannot even be matched to a session, so it is broken
590
+ # for every possible waiter. Before the shape checks this was an
591
+ # IndexError *inside* the GLib callback -- which PyGI prints and
592
+ # swallows, so the wait hung (timeout=None) or timed out, both
593
+ # indistinguishable from a compositor that never fired.
594
+ connection = FakeInputCaptureConnection(pending_activated_raw=())
595
+ with install_fake_gi(connection):
596
+ session = portal.InputCaptureSession.negotiate(connection=connection)
597
+ with pytest.raises(portal.PortalProtocolError) as excinfo:
598
+ session.wait_for_activation(timeout=1.0)
599
+ # `.step` is the attribute every sibling portal error raised from the
600
+ # same call site carries, so a caller that catches PortalError and reads
601
+ # `.step` works for this one too (PortalTimeoutError/SessionClosedError).
602
+ assert excinfo.value.step == "Activated"
603
+ assert excinfo.value.payload == ()
604
+ # A shape fault, so no value-level detail.
605
+ assert excinfo.value.detail is None
606
+
607
+
608
+ def test_a_malformed_deactivated_payload_raises_a_typed_error() -> None:
609
+ # Same shape check, the other signal: wait_for_deactivation goes through
610
+ # the identical _wait_for_signal, and nothing else exercises its
611
+ # malformed branch (the timeout/closed paths test both signals, so this
612
+ # one should too).
613
+ connection = FakeInputCaptureConnection(pending_deactivated_raw=("/session/1",))
614
+ with install_fake_gi(connection):
615
+ session = portal.InputCaptureSession.negotiate(connection=connection)
616
+ with pytest.raises(portal.PortalProtocolError) as excinfo:
617
+ session.wait_for_deactivation(timeout=1.0)
618
+ assert excinfo.value.step == "Deactivated"
619
+ assert excinfo.value.payload == ("/session/1",)
620
+
621
+
622
+ def test_a_non_integer_activation_id_raises_a_typed_error() -> None:
623
+ # Shape-valid payload (a dict), but activation_id is not the `u` the
624
+ # spec promises -- this passes _wait_for_signal's shape check and used
625
+ # to crash the caller with ValueError on int(). It is now the same
626
+ # typed error as a shape violation, and carries a value-level detail so
627
+ # its message does not misdiagnose a correct shape.
628
+ connection = FakeInputCaptureConnection(
629
+ pending_activated={"activation_id": "not-a-number"}
630
+ )
631
+ with install_fake_gi(connection):
632
+ session = portal.InputCaptureSession.negotiate(connection=connection)
633
+ with pytest.raises(portal.PortalProtocolError) as excinfo:
634
+ session.wait_for_activation(timeout=1.0)
635
+ assert excinfo.value.step == "Activated"
636
+ assert excinfo.value.detail is not None
637
+ assert "shape" not in str(excinfo.value)
638
+
639
+
640
+ def test_a_non_iterable_cursor_position_raises_a_typed_error() -> None:
641
+ # cursor_position is `(dd)`; a scalar here would crash the caller with
642
+ # TypeError unpacking it. Guarded into PortalProtocolError too.
643
+ connection = FakeInputCaptureConnection(
644
+ pending_activated={"activation_id": 1, "cursor_position": 5}
645
+ )
646
+ with install_fake_gi(connection):
647
+ session = portal.InputCaptureSession.negotiate(connection=connection)
648
+ with pytest.raises(portal.PortalProtocolError):
649
+ session.wait_for_activation(timeout=1.0)
650
+
651
+
652
+ def test_a_wrong_arity_cursor_position_raises_a_typed_error() -> None:
653
+ # (dd) is exactly two: tuple("ab") would not raise, so the old guard let
654
+ # a wrong-arity or stringly-typed cursor through as a bad Activation.
655
+ # The explicit two-element unpack rejects it.
656
+ for bad in ("ab", (1.0,), (1.0, 2.0, 3.0)):
657
+ connection = FakeInputCaptureConnection(
658
+ pending_activated={"activation_id": 1, "cursor_position": bad}
659
+ )
660
+ with install_fake_gi(connection):
661
+ session = portal.InputCaptureSession.negotiate(connection=connection)
662
+ with pytest.raises(portal.PortalProtocolError):
663
+ session.wait_for_activation(timeout=1.0)
664
+
665
+
666
+ def test_a_non_numeric_cursor_component_raises_a_typed_error() -> None:
667
+ connection = FakeInputCaptureConnection(
668
+ pending_activated={"activation_id": 1, "cursor_position": ("x", "y")}
669
+ )
670
+ with install_fake_gi(connection):
671
+ session = portal.InputCaptureSession.negotiate(connection=connection)
672
+ with pytest.raises(portal.PortalProtocolError):
673
+ session.wait_for_activation(timeout=1.0)
674
+
675
+
676
+ def test_a_non_integer_barrier_id_raises_a_typed_error() -> None:
677
+ # barrier_id is a `u` the caller compares against the ids it armed; a
678
+ # string here used to pass straight through into a type-violating
679
+ # Activation that mismatched silently or crashed on arithmetic later.
680
+ connection = FakeInputCaptureConnection(
681
+ pending_activated={"activation_id": 1, "barrier_id": "b-3"}
682
+ )
683
+ with install_fake_gi(connection):
684
+ session = portal.InputCaptureSession.negotiate(connection=connection)
685
+ with pytest.raises(portal.PortalProtocolError):
686
+ session.wait_for_activation(timeout=1.0)
687
+
688
+
689
+ def test_an_infinite_activation_id_raises_a_typed_error() -> None:
690
+ # int(float('inf')) raises OverflowError, which the old (ValueError,
691
+ # TypeError) guard missed -- it would have escaped a caller's
692
+ # `except PortalError`.
693
+ connection = FakeInputCaptureConnection(
694
+ pending_activated={"activation_id": float("inf")}
695
+ )
696
+ with install_fake_gi(connection):
697
+ session = portal.InputCaptureSession.negotiate(connection=connection)
698
+ with pytest.raises(portal.PortalProtocolError):
699
+ session.wait_for_activation(timeout=1.0)
700
+
701
+
702
+ def test_a_non_integer_deactivation_id_raises_a_typed_error() -> None:
703
+ connection = FakeInputCaptureConnection(
704
+ pending_deactivated={"activation_id": "not-a-number"}
705
+ )
706
+ with install_fake_gi(connection):
707
+ session = portal.InputCaptureSession.negotiate(connection=connection)
708
+ with pytest.raises(portal.PortalProtocolError) as excinfo:
709
+ session.wait_for_deactivation(timeout=1.0)
710
+ assert excinfo.value.step == "Deactivated"
711
+
712
+
713
+ def test_an_options_payload_that_is_not_a_dict_raises_a_typed_error() -> None:
714
+ # The right session's signal, but options is not the a{sv} the spec
715
+ # promises -- before the shape checks this surfaced wherever the caller
716
+ # happened to poke the payload (an AttributeError on .get here).
717
+ connection = FakeInputCaptureConnection(pending_activated_raw=("/session/1", 42))
718
+ with install_fake_gi(connection):
719
+ session = portal.InputCaptureSession.negotiate(connection=connection)
720
+ with pytest.raises(portal.PortalProtocolError) as excinfo:
721
+ session.wait_for_activation(timeout=1.0)
722
+ assert "Activated" in str(excinfo.value)
723
+ assert excinfo.value.payload == ("/session/1", 42)
724
+
725
+
726
+ def test_a_non_string_session_handle_raises_a_typed_error() -> None:
727
+ connection = FakeInputCaptureConnection(pending_activated_raw=(7, {}))
728
+ with install_fake_gi(connection):
729
+ session = portal.InputCaptureSession.negotiate(connection=connection)
730
+ with pytest.raises(portal.PortalProtocolError):
731
+ session.wait_for_activation(timeout=1.0)
732
+
733
+
734
+ def test_a_payload_with_an_extra_trailing_field_raises_a_typed_error() -> None:
735
+ # The right session and a valid options dict, but a third field the
736
+ # spec's (o, a{sv}) shape has no room for -- pins the `len(args) != 2`
737
+ # clause on its own, which the not-a-dict case above does not reach.
738
+ connection = FakeInputCaptureConnection(
739
+ pending_activated_raw=("/session/1", {}, "extra")
740
+ )
741
+ with install_fake_gi(connection):
742
+ session = portal.InputCaptureSession.negotiate(connection=connection)
743
+ with pytest.raises(portal.PortalProtocolError) as excinfo:
744
+ session.wait_for_activation(timeout=1.0)
745
+ assert excinfo.value.payload == ("/session/1", {}, "extra")
746
+
747
+
569
748
  def test_wait_for_activation_times_out_with_no_signal() -> None:
570
749
  connection = FakeInputCaptureConnection()
571
750
  with install_fake_gi(connection):
@@ -598,6 +777,44 @@ def test_wait_for_activation_does_not_double_remove_the_timeout_source_on_timeou
598
777
  source_remove.assert_not_called()
599
778
 
600
779
 
780
+ def test_a_signal_and_the_deadline_in_one_poll_do_not_double_remove_the_source() -> (
781
+ None
782
+ ):
783
+ # The harder half of the same bug: GLib dispatches every source ready in
784
+ # one iteration, so a real Activated can land in the *same poll* as the
785
+ # timeout deadline. on_signal records the activation and quits; on_timeout
786
+ # still runs, sees the wait already settled, and returns False -- which
787
+ # GLib treats as "remove this source". The finally block must not remove
788
+ # it a second time. Keyed on `timed_out` (which this early return leaves
789
+ # False) rather than on "did on_timeout fire", it did -- tripping the
790
+ # "Source ID N was not found" critical that is fatal under
791
+ # G_DEBUG=fatal-criticals. The fake delivers the signal in the same run()
792
+ # as the timeout via inject_before_timeout to model the race.
793
+ connection = FakeInputCaptureConnection() # nothing fires on subscribe
794
+
795
+ def deliver_activation() -> None:
796
+ key = (portal._INPUT_CAPTURE, "Activated", portal._OBJECT_PATH)
797
+ connection._subscriptions[key](
798
+ None,
799
+ None,
800
+ portal._OBJECT_PATH,
801
+ None,
802
+ "Activated",
803
+ FakeReply((connection.session_handle, {"activation_id": 7})),
804
+ )
805
+
806
+ with install_fake_gi(connection):
807
+ glib = sys.modules["gi.repository.GLib"]
808
+ session = portal.InputCaptureSession.negotiate(connection=connection)
809
+ FakeMainLoop.inject_before_timeout = deliver_activation
810
+ with mock.patch.object(glib, "source_remove") as source_remove:
811
+ activation = session.wait_for_activation(timeout=5.0)
812
+ # The delivered signal wins -- not a timeout -- and the source the firing
813
+ # on_timeout already removed is not removed again.
814
+ assert activation.activation_id == 7
815
+ source_remove.assert_not_called()
816
+
817
+
601
818
  def test_wait_for_activation_subscribes_on_the_portal_not_the_session() -> None:
602
819
  # The bug that made this whole feature look like a compositor gap: the
603
820
  # portal emits Activated on its own object, never on the session's, so
@@ -112,6 +112,11 @@ class FakeMainLoop:
112
112
  # What GLib.timeout_add was asked to wait, so a test can check the
113
113
  # Response leg got only the time the call leg left of the deadline.
114
114
  pending_timeout_msec: int | None = None
115
+ # A callable a test can set to model real GLib dispatching several
116
+ # sources ready in one iteration: it runs just before the stashed
117
+ # timeout callback, so a signal it delivers and the timeout both fire in
118
+ # the same `run()`. Left None, `run()` behaves exactly as before.
119
+ inject_before_timeout: Any = None
115
120
 
116
121
  def __init__(self) -> None:
117
122
  self._quit = False
@@ -122,6 +127,10 @@ class FakeMainLoop:
122
127
  callback = FakeMainLoop.pending_timeout
123
128
  if callback is not None:
124
129
  FakeMainLoop.pending_timeout = None
130
+ inject = FakeMainLoop.inject_before_timeout
131
+ if inject is not None:
132
+ FakeMainLoop.inject_before_timeout = None
133
+ inject()
125
134
  callback()
126
135
  return
127
136
  raise AssertionError("run() called before a synchronous quit()")
@@ -296,6 +305,8 @@ def install_fake_gi(connection: Any = None) -> Any:
296
305
 
297
306
  # timeout_add stashes the callback rather than scheduling it; only the
298
307
  # timeout tests, which never quit() the loop, actually let it fire.
308
+ FakeMainLoop.inject_before_timeout = None
309
+
299
310
  def timeout_add(ms: int, callback: Any) -> int:
300
311
  FakeMainLoop.pending_timeout = callback
301
312
  FakeMainLoop.pending_timeout_msec = ms
File without changes
File without changes