python-libei 0.7.0__py3-none-any.whl → 0.8.1__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.7.0"
34
+ __version__ = "0.8.1"
35
35
 
36
36
  __all__ = ["__version__"]
libei/_capi/libei.py CHANGED
@@ -219,7 +219,13 @@ region_get_mapping_id = lib.function( # libei 1.1+
219
219
  "ei_region_get_mapping_id", (c_void_p,), c_char_p
220
220
  )
221
221
  # x and y are in/out parameters, overwritten only when the point is inside.
222
- region_convert_point = lib.function( # libei 1.1+
222
+ # No version marker: this one is in libei 1.0.0's own public header and
223
+ # implemented there, so every libei at or above the declared floor exports
224
+ # it. It carried `# libei 1.1+` alongside its genuine 1.1 neighbours
225
+ # `ei_region_get_mapping_id` and `ei_device_region_at`, and the weekly
226
+ # floor job caught it -- building 1.0.0 from source found the symbol
227
+ # present where the marker promised it would be absent.
228
+ region_convert_point = lib.function(
223
229
  "ei_region_convert_point",
224
230
  (c_void_p, POINTER(c_double), POINTER(c_double)),
225
231
  c_bool,
libei/ei.py CHANGED
@@ -372,8 +372,7 @@ class Region(CObject):
372
372
 
373
373
  Returns the point with the region's offset subtracted, or ``None``
374
374
  if it falls outside the region -- which is also how you test
375
- membership without a second :meth:`contains` call. Requires
376
- libei 1.1.
375
+ membership without a second :meth:`contains` call.
377
376
  """
378
377
  # x/y are in-out parameters: libei overwrites them only when the
379
378
  # point is inside, so the return value has to gate reading them.
@@ -1127,7 +1126,10 @@ class Context(CObject):
1127
1126
  pointer = _capi.libei.get_event(self)
1128
1127
  if not pointer:
1129
1128
  break
1130
- event = Event.wrap(pointer)
1129
+ # adopt(), not wrap(): ei_get_event() transfers ownership of
1130
+ # the returned reference to the caller, and the unref in
1131
+ # release() below is the one that balances it.
1132
+ event = Event.adopt(pointer)
1131
1133
  assert event is not None
1132
1134
  # try/finally, not a bare call after yield: breaking out of a
1133
1135
  # `for event in ctx.events:` loop (or an exception propagating
libei/eis.py CHANGED
@@ -944,7 +944,18 @@ class Event(CObject):
944
944
 
945
945
  @property
946
946
  def emulating_sequence(self) -> int:
947
- """Sequence number of the start_emulating transaction."""
947
+ """Sequence number of the start_emulating transaction.
948
+
949
+ Reads 0 on libei older than 1.6, whatever the client sent. libeis
950
+ queued the event without ever copying the sequence into it, so the
951
+ getter returned a zeroed struct field; upstream added the missing
952
+ assignment in libei 1.6.0. Every version from this package's 1.0.0
953
+ floor up exports the symbol, so there is nothing to raise and no
954
+ way to tell an old library apart from a client that genuinely sent
955
+ 0 -- treat a 0 here as "unknown" unless you know both ends are on
956
+ 1.6. :attr:`libei.ei.Event.emulating_sequence`, the receiver side
957
+ of the same transaction, is unaffected: libei has always stored it.
958
+ """
948
959
  self._require("emulating_sequence", EventType.DEVICE_START_EMULATING)
949
960
  return _capi.libeis.event_emulating_get_sequence(self)
950
961
 
@@ -1150,7 +1161,10 @@ class Eis(CObject):
1150
1161
  pointer = _capi.libeis.get_event(self)
1151
1162
  if not pointer:
1152
1163
  break
1153
- event = Event.wrap(pointer)
1164
+ # adopt(), not wrap(): eis_get_event() transfers ownership of
1165
+ # the returned reference to the caller, and the unref in
1166
+ # release() below is the one that balances it.
1167
+ event = Event.adopt(pointer)
1154
1168
  assert event is not None
1155
1169
  # See ei.Context.events: try/finally so release() still runs
1156
1170
  # if the caller breaks out of the loop (GeneratorExit at the
@@ -1168,9 +1182,11 @@ class Eis(CObject):
1168
1182
  def set_flag(self, flag: Flag) -> None:
1169
1183
  """Change this context's protocol behavior. Requires libei 1.6.
1170
1184
 
1171
- Must be called before the backend is set up, so in practice
1172
- before :meth:`create_for_fd` / :meth:`create_for_socket` -- which
1173
- also means this is only reachable on a context built by hand.
1185
+ Must be called before the backend is set up, so the supported
1186
+ way to use it is the ``flags`` parameter of
1187
+ :meth:`create_for_fd` / :meth:`create_for_socket`, which apply
1188
+ each flag at exactly that point; calling it directly only works
1189
+ on a context built by hand, before its backend is set up.
1174
1190
  Takes one flag, never a bitmask; call it again for another.
1175
1191
  """
1176
1192
  result = _capi.libeis.set_flag(self, flag)
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,53 @@ 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
+ :func:`_request` raises it the same way for a ``Request``'s
212
+ ``Response`` reply that does not match its own ``(u response, a{sv}
213
+ results)`` shape, and :meth:`InputCaptureSession.zones` for a result
214
+ value that is not the type the spec gives it -- always with a
215
+ ``detail`` phrase, since the default shape message above is specific
216
+ to the session signals. ``step`` is then the method name, matching the
217
+ sibling errors raised from the same call.
218
+ """
219
+
220
+ def __init__(self, step: str, payload: object, detail: str | None = None) -> None:
221
+ """Name the signal, what arrived, and -- for a value fault -- which."""
222
+ reason = detail or (
223
+ "the portal spec gives it the shape (session_handle, options dict)"
224
+ )
225
+ super().__init__(
226
+ f"{step} arrived with a malformed payload: {reason}, got {payload!r}"
227
+ )
228
+ # `step` mirrors the attribute every sibling portal error raised
229
+ # from the same `_wait_for_signal` call site carries
230
+ # (PortalTimeoutError, PortalSessionClosedError), so a caller that
231
+ # catches PortalError and reads `.step` works across all of them.
232
+ self.step = step
233
+ self.payload = payload
234
+ self.detail = detail
235
+
236
+
188
237
  class PortalSessionClosedError(PortalError):
189
238
  """The portal session ended while something was still waiting on it.
190
239
 
@@ -416,6 +465,18 @@ def _request(
416
465
  for 60 seconds means the answer arrives inside 60 seconds, not inside
417
466
  however many 60-second waits the sequence happens to be built from.
418
467
 
468
+ A ``Response`` whose payload does not match the spec's ``(u response,
469
+ a{sv} results)`` shape raises :class:`PortalProtocolError` rather than
470
+ being unpacked on faith -- the same hardening `_wait_for_signal`
471
+ applies to the signals it waits on, and for the same two reasons: a
472
+ wrong-arity payload used to raise inside the GLib callback, where PyGI
473
+ swallows it, degrading the step to a full-timeout
474
+ :class:`PortalTimeoutError`; a non-dict ``results`` instead slipped
475
+ through to the caller, whose ``.get()`` crashed with a raw
476
+ ``AttributeError`` past ``except PortalError``. The guarantee callers
477
+ rely on is therefore structural: what this returns is always an
478
+ ``int`` code and a ``dict`` of results.
479
+
419
480
  ``trailing_args``, appended after ``options`` in the call's parameter
420
481
  tuple, exists for ``InputCapture.SetPointerBarriers`` -- the one
421
482
  Request-returning method in either portal whose ``options`` is not its
@@ -433,12 +494,28 @@ def _request(
433
494
 
434
495
  loop = GLib.MainLoop()
435
496
  result: dict[str, Any] = {}
497
+ malformed: dict[str, Any] = {}
436
498
  subscriptions: list[Any] = []
437
499
  # Set inside the timeout callback rather than inferred from an empty
438
500
  # `result` afterwards: a Response carrying no results is legitimate
439
501
  # (SelectDevices answers with an empty dict), so "did the loop end
440
502
  # because it timed out" has to be recorded, not deduced.
441
503
  timed_out = False
504
+ # Distinct from `timed_out`: whether `on_timeout` ran at all, either
505
+ # path -- the flag the cleanup below keys "already removed?" on. See
506
+ # the matching pair in `_wait_for_signal`.
507
+ timeout_fired = False
508
+
509
+ def _settled() -> bool:
510
+ """Whether some terminal outcome has already been recorded.
511
+
512
+ Same role as in `_wait_for_signal`: GLib dispatches every source
513
+ ready in one main-context iteration regardless of an earlier
514
+ ``loop.quit()``, so a Response and the deadline can both fire in
515
+ the same poll -- the first recorded outcome wins, and a delivered
516
+ reply is never dropped for a timeout (or vice versa).
517
+ """
518
+ return bool(result) or bool(malformed) or timed_out
442
519
 
443
520
  def on_response(
444
521
  _conn: Any,
@@ -450,14 +527,42 @@ def _request(
450
527
  *_a: Any,
451
528
  ) -> None:
452
529
  """Record the first reply and quit the loop; later replies are ignored."""
453
- if result: # both subscriptions may fire; the first reply wins
530
+ if _settled(): # both subscriptions may fire; the first reply wins
454
531
  return
455
- result["code"], result["results"] = params.unpack()
532
+ args = params.unpack()
533
+ # Checked here, raised only after the loop: this runs inside a
534
+ # GLib callback, where PyGI prints an exception to stderr and
535
+ # swallows it -- a bare raise on a malformed Response would leave
536
+ # the loop running until the full timeout, misreported as
537
+ # PortalTimeoutError; a non-dict `results` let through instead
538
+ # crashed the caller's `.get()` with a raw AttributeError past
539
+ # `except PortalError`. Same hardening `_wait_for_signal` applies
540
+ # to the signals it waits on.
541
+ if (
542
+ not isinstance(args, tuple)
543
+ or len(args) != 2
544
+ or isinstance(args[0], bool)
545
+ or not isinstance(args[0], int)
546
+ or not isinstance(args[1], dict)
547
+ ):
548
+ malformed["args"] = args
549
+ loop.quit()
550
+ return
551
+ result["code"], result["results"] = args
456
552
  loop.quit()
457
553
 
458
554
  def on_timeout() -> bool:
459
555
  """Stop the loop and record that it timed out rather than finished."""
460
- nonlocal timed_out
556
+ nonlocal timed_out, timeout_fired
557
+ # Set first, before the early return: GLib removes the source the
558
+ # moment this returns False (which both paths do), so the finally
559
+ # block keys "already removed" on this, not on `timed_out` -- see
560
+ # the same dance in `_wait_for_signal`.
561
+ timeout_fired = True
562
+ if _settled():
563
+ # A Response delivered in the same poll as the deadline has
564
+ # already recorded its outcome; do not overwrite it.
565
+ return False
461
566
  timed_out = True
462
567
  loop.quit()
463
568
  return False # one-shot; GLib removes the source when this is False
@@ -501,12 +606,14 @@ def _request(
501
606
  handle = _returned_handle(reply)
502
607
  if handle is not None and handle != expected_path:
503
608
  subscribe(handle)
504
- # `if not result` because a synchronous answer (a fast
609
+ # `if not _settled()` because a synchronous answer (a fast
505
610
  # non-interactive Response, or a test double) can arrive during the
506
611
  # call above, before run() is reached -- and quit() on a loop that
507
612
  # is not running yet does not stop the later run(), so running it
508
- # then would block with the reply already delivered.
509
- if not result:
613
+ # then would block with the reply already delivered. A malformed
614
+ # synchronous reply counts: it too has already quit a loop that
615
+ # was not running yet.
616
+ if not _settled():
510
617
  timeout_source = GLib.timeout_add(_msec_until(deadline), on_timeout)
511
618
  try:
512
619
  loop.run()
@@ -518,12 +625,27 @@ def _request(
518
625
  # fixed for _wait_for_signal below. Only remove it when the
519
626
  # *Response* woke the loop instead; leaving a live source in
520
627
  # that case holds a reference to this closure and fires it
521
- # into a dead loop on some later request.
522
- if not timed_out:
628
+ # into a dead loop on some later request. Keyed on
629
+ # `timeout_fired` (did on_timeout run), not `timed_out` (did
630
+ # the wait time out): a Response landing in the same poll as
631
+ # the deadline runs on_timeout down its `_settled` early
632
+ # return, which removes the source yet leaves `timed_out`
633
+ # False.
634
+ if not timeout_fired:
523
635
  GLib.source_remove(timeout_source)
524
636
  finally:
525
637
  for subscription in subscriptions:
526
638
  connection.signal_unsubscribe(subscription)
639
+ # Mirrors `_signal_outcome`: only one of these is ever set (every
640
+ # callback checks `_settled()` first), so the order here only decides
641
+ # between genuinely simultaneous dispatches.
642
+ if malformed:
643
+ raise PortalProtocolError(
644
+ method,
645
+ malformed["args"],
646
+ "the Request's Response signal is specified as "
647
+ "(uint response, results dict)",
648
+ )
527
649
  if timed_out:
528
650
  raise PortalTimeoutError(method, timeout)
529
651
  return result["code"], result["results"]
@@ -1021,6 +1143,114 @@ def _create_input_capture_session_v1(
1021
1143
  return session_handle
1022
1144
 
1023
1145
 
1146
+ def _classify_signal_payload(
1147
+ args: tuple[Any, ...], session_handle: str, signal: str
1148
+ ) -> str:
1149
+ """How `_wait_for_signal` should treat one unpacked signal payload.
1150
+
1151
+ ``"take"`` -- it is this session's, correctly shaped, record it.
1152
+ ``"drop"`` -- another session's; keep waiting (logged, since a dropped
1153
+ signal is otherwise indistinguishable from one that never fired).
1154
+ ``"malformed"`` -- not the ``(o session_handle, a{sv} options)`` shape
1155
+ the spec gives every signal here.
1156
+
1157
+ Two shape checks with the session filter between them, so how a
1158
+ malformed payload is treated depends on how it is broken. A payload
1159
+ whose *options* are the wrong shape (the second check) is reached only
1160
+ after the filter has matched its handle, so another session's is already
1161
+ dropped before that check runs -- it is raised only for this session. A
1162
+ payload with no first element, or one that is not a string, cannot be
1163
+ matched to any session at all: there is no handle to compare it with, so
1164
+ it is malformed for every possible waiter and is raised here whichever
1165
+ session it might have been meant for -- the filter cannot run before it,
1166
+ only after. In the single-session process this almost always is, the
1167
+ distinction never arises.
1168
+ """
1169
+ if not args or not isinstance(args[0], str):
1170
+ return "malformed"
1171
+ if args[0] != session_handle:
1172
+ logger.debug(
1173
+ "ignoring %s for session %s while waiting for %s",
1174
+ signal,
1175
+ args[0],
1176
+ session_handle,
1177
+ )
1178
+ return "drop"
1179
+ if len(args) != 2 or not isinstance(args[1], dict):
1180
+ return "malformed"
1181
+ return "take"
1182
+
1183
+
1184
+ def _spec_int(value: Any) -> int:
1185
+ """``int(value)`` for a spec ``u``/``i``, refusing a silent truncation.
1186
+
1187
+ A portal integer field arrives as a Python ``int``. A malformed portal
1188
+ that put a ``d`` (double) there would still reach ``int()``, and
1189
+ ``int(0.9) == 0`` / ``int(1.9) == 1`` truncates it *silently* -- a zone
1190
+ coordinate or a refused barrier id quietly becomes a different,
1191
+ valid-looking value rather than an error. Reject any ``float``
1192
+ (``inf``/``nan`` included) with the ``ValueError`` the surrounding
1193
+ guards already turn into a :class:`PortalProtocolError`, so an off-spec
1194
+ fractional value is refused on the same footing as a non-numeric one.
1195
+ """
1196
+ if isinstance(value, float):
1197
+ raise ValueError("a fractional value where the spec gives an integer")
1198
+ return int(value)
1199
+
1200
+
1201
+ def _deactivation_id(options: dict[str, Any], signal: str) -> int:
1202
+ """The ``activation_id`` from Deactivated's options, as the spec's ``u``.
1203
+
1204
+ ``_wait_for_signal`` checks the payload's *shape* -- that options is a
1205
+ dict -- but not the types of the values in it. ``activation_id`` is a
1206
+ ``u``, and a non-conforming portal that put something else there would
1207
+ crash the caller with ``ValueError``/``TypeError``/``OverflowError``
1208
+ past the shape check. Converting it here turns that into the same typed
1209
+ :class:`PortalProtocolError` the shape check raises, so a caller that
1210
+ guards against malformed signals catches one error, not two.
1211
+ """
1212
+ try:
1213
+ return _spec_int(options.get("activation_id", 0))
1214
+ except (ValueError, TypeError, OverflowError) as exc:
1215
+ raise PortalProtocolError(
1216
+ signal, options, "activation_id is not an integer"
1217
+ ) from exc
1218
+
1219
+
1220
+ def _activation_from_options(options: dict[str, Any], signal: str) -> Activation:
1221
+ """Build an :class:`Activation` from Activated's options, spec-typed.
1222
+
1223
+ The companion to the shape check in ``_wait_for_signal`` for the value
1224
+ level: every field the portal spec types is converted here under one
1225
+ guard, so a non-conforming value in any of them -- an ``activation_id``
1226
+ that is not a ``u``, a ``cursor_position`` that is not two ``d``s, a
1227
+ ``barrier_id`` that is not a ``u`` -- becomes the same typed
1228
+ :class:`PortalProtocolError` rather than a crash in the caller later,
1229
+ doing arithmetic on a string. One guard rather than three means the
1230
+ next field added to the signal is validated by construction, not by a
1231
+ fourth hand-rolled check nobody remembers to write.
1232
+ """
1233
+ try:
1234
+ activation_id = _spec_int(options.get("activation_id", 0))
1235
+ cursor = options.get("cursor_position")
1236
+ if cursor is None:
1237
+ cursor_position = None
1238
+ else:
1239
+ cx, cy = cursor # (dd): exactly two, or this unpacking raises
1240
+ cursor_position = (float(cx), float(cy))
1241
+ barrier = options.get("barrier_id")
1242
+ barrier_id = None if barrier is None else _spec_int(barrier)
1243
+ except (ValueError, TypeError, OverflowError) as exc:
1244
+ raise PortalProtocolError(
1245
+ signal, options, "an option value is not the type the spec gives it"
1246
+ ) from exc
1247
+ return Activation(
1248
+ activation_id=activation_id,
1249
+ cursor_position=cursor_position,
1250
+ barrier_id=barrier_id,
1251
+ )
1252
+
1253
+
1024
1254
  def _wait_for_signal(
1025
1255
  connection: Any,
1026
1256
  Gio: Any,
@@ -1056,12 +1286,41 @@ def _wait_for_signal(
1056
1286
  D-Bus specification restricts plain ``arg0=`` match rules to arguments
1057
1287
  of type STRING, and this one is an OBJECT_PATH, which is the same shape
1058
1288
  of silent non-delivery all over again.
1289
+
1290
+ A payload that does not match the spec's ``(o session_handle, a{sv}
1291
+ options)`` shape raises :class:`PortalProtocolError` rather than being
1292
+ unpacked on faith -- see that class for why the alternative was not an
1293
+ exception but a hang.
1059
1294
  """
1060
1295
  loop = GLib.MainLoop()
1061
1296
  result: dict[str, Any] = {}
1297
+ malformed: dict[str, Any] = {}
1062
1298
  timed_out = False
1299
+ # Distinct from `timed_out`: whether `on_timeout` ran at all, either
1300
+ # path. `on_timeout` returns False whichever branch it takes, and False
1301
+ # is what makes GLib deregister the source -- so the finally block must
1302
+ # key its "already removed?" check on "did on_timeout fire", not on "did
1303
+ # the wait time out". When a signal and the deadline land in the same
1304
+ # poll, on_timeout takes the `_settled()` early return (leaving
1305
+ # `timed_out` False) yet its False return still removed the source;
1306
+ # keying on `timed_out` there would double-remove it and trip the very
1307
+ # "Source ID N was not found" critical the guard exists to avoid.
1308
+ timeout_fired = False
1063
1309
  session_closed = False
1064
1310
 
1311
+ def _settled() -> bool:
1312
+ """Whether some terminal outcome has already been recorded.
1313
+
1314
+ GLib dispatches every source ready in one main-context iteration,
1315
+ regardless of an earlier ``loop.quit()``, so two outcomes -- a
1316
+ signal and the timeout, a signal and Closed -- can both fire in the
1317
+ same poll. Every callback checks this first so the *first* recorded
1318
+ outcome wins and the post-loop raise order is never handed a second
1319
+ one to misreport (a delivered activation dropped for a timeout, a
1320
+ protocol fault overwritten by session-closed).
1321
+ """
1322
+ return bool(result) or bool(malformed) or timed_out or session_closed
1323
+
1065
1324
  def on_closed(*_args: Any) -> None:
1066
1325
  """End the wait: a closed session can never deliver the signal.
1067
1326
 
@@ -1076,7 +1335,7 @@ def _wait_for_signal(
1076
1335
  that merely had not fired yet.
1077
1336
  """
1078
1337
  nonlocal session_closed
1079
- if result:
1338
+ if _settled():
1080
1339
  return
1081
1340
  logger.debug("session %s closed while waiting for %s", session_handle, signal)
1082
1341
  session_closed = True
@@ -1092,21 +1351,22 @@ def _wait_for_signal(
1092
1351
  *_a: Any,
1093
1352
  ) -> None:
1094
1353
  """Record the first matching signal; anything else is logged and dropped."""
1095
- if result: # a subscription that outlives its own wait can fire twice
1354
+ if _settled(): # a subscription outliving its wait fires twice
1096
1355
  return
1097
1356
  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
- )
1357
+ # The verdict is computed by `_classify_signal_payload`, but ACTED
1358
+ # on here and the error raised only after the loop, because this
1359
+ # runs inside a GLib callback: PyGI prints an exception raised here
1360
+ # to stderr and swallows it, so a bare raise on a malformed payload
1361
+ # would leave the loop running -- a timeout at best, an unbounded
1362
+ # hang with timeout=None, both indistinguishable from a compositor
1363
+ # that never fired.
1364
+ verdict = _classify_signal_payload(args, session_handle, signal)
1365
+ if verdict == "drop":
1366
+ return
1367
+ if verdict == "malformed":
1368
+ malformed["args"] = args
1369
+ loop.quit()
1110
1370
  return
1111
1371
  logger.debug("received %s for session %s", signal, session_handle)
1112
1372
  result["args"] = args
@@ -1114,7 +1374,16 @@ def _wait_for_signal(
1114
1374
 
1115
1375
  def on_timeout() -> bool:
1116
1376
  """Stop the loop and record that it timed out rather than finished."""
1117
- nonlocal timed_out
1377
+ nonlocal timed_out, timeout_fired
1378
+ # Set first, before the early return: GLib removes the source the
1379
+ # moment this returns False (which both paths do), so the finally
1380
+ # block keys "already removed" on this, not on `timed_out`.
1381
+ timeout_fired = True
1382
+ if _settled():
1383
+ # A signal delivered in the same poll as the deadline has
1384
+ # already recorded its outcome; the timeout must not overwrite
1385
+ # a real activation with PortalTimeoutError.
1386
+ return False
1118
1387
  timed_out = True
1119
1388
  loop.quit()
1120
1389
  return False
@@ -1148,7 +1417,7 @@ def _wait_for_signal(
1148
1417
  session_handle,
1149
1418
  )
1150
1419
  try:
1151
- if not result and not session_closed:
1420
+ if not result and not session_closed and not malformed:
1152
1421
  timeout_source = None
1153
1422
  if timeout is not None:
1154
1423
  timeout_source = GLib.timeout_add(int(timeout * 1000), on_timeout)
@@ -1164,13 +1433,41 @@ def _wait_for_signal(
1164
1433
  # every real timeout this session hit, never against
1165
1434
  # test_portal.py's fake GLib.source_remove, which just
1166
1435
  # clears pending_timeout with no complaint either way.
1167
- if timeout_source is not None and not timed_out:
1436
+ # Keyed on `timeout_fired` (did on_timeout run), not
1437
+ # `timed_out` (did the wait time out): a signal landing in
1438
+ # the same poll as the deadline runs on_timeout down its
1439
+ # `_settled` early return, which removes the source yet
1440
+ # leaves `timed_out` False.
1441
+ if timeout_source is not None and not timeout_fired:
1168
1442
  GLib.source_remove(timeout_source)
1169
1443
  finally:
1170
1444
  connection.signal_unsubscribe(subscription)
1171
1445
  connection.signal_unsubscribe(closed_subscription)
1446
+ return _signal_outcome(
1447
+ signal, timeout, result, malformed, timed_out, session_closed
1448
+ )
1449
+
1450
+
1451
+ def _signal_outcome(
1452
+ signal: str,
1453
+ timeout: float | None,
1454
+ result: dict[str, Any],
1455
+ malformed: dict[str, Any],
1456
+ timed_out: bool,
1457
+ session_closed: bool,
1458
+ ) -> tuple[Any, ...]:
1459
+ """Raise for the terminal outcome `_wait_for_signal` recorded, or return it.
1460
+
1461
+ Order is load-bearing and matches the callbacks' `_settled` guard:
1462
+ whichever outcome was recorded first is the only one set, so this order
1463
+ only ever decides between genuinely simultaneous dispatches, where a
1464
+ session that has closed or a payload that was malformed is the more
1465
+ useful answer than "timed out".
1466
+ """
1172
1467
  if session_closed:
1173
1468
  raise PortalSessionClosedError(signal)
1469
+ if malformed:
1470
+ raise PortalProtocolError(signal, malformed["args"])
1174
1471
  if timed_out:
1175
1472
  # timed_out is only ever set inside on_timeout, itself only ever
1176
1473
  # registered when timeout is not None -- so this always holds, but
@@ -1408,9 +1705,26 @@ class InputCaptureSession:
1408
1705
  )
1409
1706
  if code != 0:
1410
1707
  raise PortalDeniedError("GetZones")
1411
- zone_set = results.get("zone_set", 0)
1412
- zones = [tuple(z) for z in results.get("zones", [])]
1413
- return int(zone_set), zones
1708
+ # `results` is a dict by `_request`'s own guarantee; the *values*
1709
+ # are still the portal's to malform. The spec types them --
1710
+ # `zone_set` is a `u`, `zones` an `a(uuii)` -- so convert them
1711
+ # under one guard, the same way `_activation_from_options` covers
1712
+ # the Activated signal's values: a non-conforming value becomes
1713
+ # the typed PortalProtocolError rather than a raw
1714
+ # TypeError/ValueError past `except PortalError` in the caller.
1715
+ try:
1716
+ zone_set = _spec_int(results.get("zone_set", 0))
1717
+ zones = [
1718
+ (_spec_int(w), _spec_int(h), _spec_int(x), _spec_int(y))
1719
+ for w, h, x, y in results.get("zones", [])
1720
+ ]
1721
+ except (ValueError, TypeError, OverflowError) as exc:
1722
+ raise PortalProtocolError(
1723
+ "GetZones",
1724
+ results,
1725
+ "a result value is not the type the spec gives it",
1726
+ ) from exc
1727
+ return zone_set, zones
1414
1728
 
1415
1729
  def set_pointer_barriers(
1416
1730
  self,
@@ -1459,7 +1773,19 @@ class InputCaptureSession:
1459
1773
  )
1460
1774
  if code != 0:
1461
1775
  raise PortalDeniedError("SetPointerBarriers")
1462
- failed = list(results.get("failed_barriers", []))
1776
+ # `results` is a dict by `_request`'s own guarantee; the *value* at
1777
+ # `failed_barriers` (an `au` per the spec) is not. A non-iterable
1778
+ # there would crash the caller with a raw TypeError past
1779
+ # `except PortalError` -- the same value-level hole `zones()` guards
1780
+ # -- so a malformed one becomes the typed PortalProtocolError.
1781
+ try:
1782
+ failed = [_spec_int(b) for b in results.get("failed_barriers", [])]
1783
+ except (ValueError, TypeError, OverflowError) as exc:
1784
+ raise PortalProtocolError(
1785
+ "SetPointerBarriers",
1786
+ results,
1787
+ "a result value is not the type the spec gives it",
1788
+ ) from exc
1463
1789
  # A partially-refused set is the quiet failure here: the call
1464
1790
  # succeeds, some barriers stand, and an edge the caller believes is
1465
1791
  # armed simply never triggers. Callers see only the returned list,
@@ -1497,12 +1823,7 @@ class InputCaptureSession:
1497
1823
  self.session_handle,
1498
1824
  timeout,
1499
1825
  )
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
- )
1826
+ return _activation_from_options(options, "Activated")
1506
1827
 
1507
1828
  def wait_for_deactivation(self, timeout: float | None = None) -> int:
1508
1829
  """Block until the current capture ends, returning its activation_id."""
@@ -1520,7 +1841,7 @@ class InputCaptureSession:
1520
1841
  self.session_handle,
1521
1842
  timeout,
1522
1843
  )
1523
- return int(options.get("activation_id", 0))
1844
+ return _deactivation_id(options, "Deactivated")
1524
1845
 
1525
1846
  def close(self) -> None:
1526
1847
  """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.1
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.1`), 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
@@ -228,17 +228,27 @@ Exactly what was run, when, and against which versions:
228
228
 
229
229
  | Needs | For |
230
230
  | --- | --- |
231
- | 1.1 | `Region.mapping_id`, `Region.convert_point()`, `Device.region_at()` |
231
+ | 1.1 | `Region.mapping_id`, `Device.region_at()` |
232
232
  | 1.4 | ping/pong round trips (`Context.new_ping()`), `Context.disconnect()`, touch cancellation (`Touch.cancel()`) |
233
233
  | 1.5 | `eis.Client.pid` |
234
234
  | 1.6 | `Device.text_utf8()` / `text_keysym()` and TEXT events, `Seat.request_device()`, `Eis.set_flag()` |
235
235
 
236
236
  Nothing is resolved until it is called, so a build without one of these
237
237
  costs you only that call — it raises `LibraryNotFoundError` naming the
238
- missing symbol, while the rest of the package keeps working. The one
239
- exception is `Event.touch_up_event`, which degrades instead of raising:
240
- on libei older than 1.4 it reports `is_cancel=False`, since a library
241
- with no notion of cancellation genuinely never sends one.
238
+ missing symbol, while the rest of the package keeps working. Two
239
+ accessors degrade instead of raising, because every supported version
240
+ exports the symbol and only the answer changed — so there is nothing to
241
+ resolve and nothing to report:
242
+
243
+ - `Event.touch_up_event` reports `is_cancel=False` on libei older than
244
+ 1.4, since a library with no notion of cancellation genuinely never
245
+ sends one.
246
+ - `eis.Event.emulating_sequence` reads 0 on libei older than 1.6
247
+ whatever the client sent: libeis queued the start_emulating event
248
+ without ever copying the sequence into it, until 1.6.0 added the
249
+ missing assignment. A 0 on an older build is indistinguishable from a
250
+ client that really sent 0. The client-side
251
+ `ei.Event.emulating_sequence` is unaffected.
242
252
 
243
253
  These versions were established by building libei 1.2.1 and diffing its
244
254
  exported symbols against every binding here, not by reading `@since`
@@ -0,0 +1,17 @@
1
+ libei/__init__.py,sha256=7fJVf_RPxcEEeOM2YTQHtJPP3ilPhFREQKNmCoJqOL4,1858
2
+ libei/_cobject.py,sha256=XjbUNJpZ87jAyPWh6dmRslk6cMy831oMRFhWmfjLDFo,13344
3
+ libei/ei.py,sha256=3oaZ4dxo-ytBHtES3_1_LUca2ZbR8r3SwA50Rj0hGxo,49541
4
+ libei/eis.py,sha256=kcFi6vfw9F9uBt6Bz-blGD-0M6mLEMTYDCgC_SKK7vU,46843
5
+ libei/oeffis.py,sha256=gJHR90s9VhQrgkjWUMDx-lxZ8qKxkttnqbx_xo-7oxs,10035
6
+ libei/portal.py,sha256=95f3I5q8BF2ehGP6LEEMzRKTisrwBxkf20rY1MvD1ic,88168
7
+ libei/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ libei/_capi/__init__.py,sha256=raISSWSV9hAcE92DcLCz-vUb1IPcsmxRZaS-iAoQJBE,211
9
+ libei/_capi/libei.py,sha256=AQgVZ2ZsHH8rywwcmq1EJ-IauuNHbBMhwauMelX83yE,11754
10
+ libei/_capi/libeis.py,sha256=C_Uh7DwNncFC8A4wuMhviAj5C_TUmKHtLJYq0Qhhz1E,13142
11
+ libei/_capi/liboeffis.py,sha256=V8jdmJm3qOQUzzNiCAXt4Jr2JY11Y4-w6NIfIxbxGI4,1218
12
+ libei/_capi/loader.py,sha256=xW67uk35ckgeFaheDGrHC05ZM5w_cxBGvX5lG_GUSu8,6625
13
+ python_libei-0.8.1.dist-info/licenses/LICENSE,sha256=l6xbMU6Y-JZDzmBciBj-J5t6h6jNg_Bcyp4bHxDepqU,1074
14
+ python_libei-0.8.1.dist-info/METADATA,sha256=gGc0OayDeot7yFnA2wmURo_xZjjr1UoQxpK7mdynrWk,23364
15
+ python_libei-0.8.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
16
+ python_libei-0.8.1.dist-info/top_level.txt,sha256=_DQXzGjDsUBENI_cNkiOxPB4xi8coCbQS1lq18FMudQ,6
17
+ python_libei-0.8.1.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
 
@@ -1,17 +0,0 @@
1
- libei/__init__.py,sha256=3Ru2esZsveT5oIlBPJ_6DH8RsRoofVvvf27WZafcOZg,1858
2
- libei/_cobject.py,sha256=XjbUNJpZ87jAyPWh6dmRslk6cMy831oMRFhWmfjLDFo,13344
3
- libei/ei.py,sha256=W4akKc_BNVfL0FroSXLGD1Z_L3OmYjZT5QDHNnEtMP8,49367
4
- libei/eis.py,sha256=X_umAaAAbAMC9d2RdnMReeJZI61WsnKz3cySY5YrGr0,45841
5
- libei/oeffis.py,sha256=gJHR90s9VhQrgkjWUMDx-lxZ8qKxkttnqbx_xo-7oxs,10035
6
- libei/portal.py,sha256=Q2GbZBUnKJZ90WJtDtpkcgQbvsCLY6xJwngmYRg5qQ0,72089
7
- libei/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
- libei/_capi/__init__.py,sha256=raISSWSV9hAcE92DcLCz-vUb1IPcsmxRZaS-iAoQJBE,211
9
- libei/_capi/libei.py,sha256=qsccHa_komCxtvPYi6AobszhFbEG2qA8QYJJVr8SaGM,11356
10
- libei/_capi/libeis.py,sha256=C_Uh7DwNncFC8A4wuMhviAj5C_TUmKHtLJYq0Qhhz1E,13142
11
- libei/_capi/liboeffis.py,sha256=V8jdmJm3qOQUzzNiCAXt4Jr2JY11Y4-w6NIfIxbxGI4,1218
12
- libei/_capi/loader.py,sha256=xW67uk35ckgeFaheDGrHC05ZM5w_cxBGvX5lG_GUSu8,6625
13
- python_libei-0.7.0.dist-info/licenses/LICENSE,sha256=l6xbMU6Y-JZDzmBciBj-J5t6h6jNg_Bcyp4bHxDepqU,1074
14
- python_libei-0.7.0.dist-info/METADATA,sha256=kCY_c4mpI43wAk_72MFq1yyv3VZm8r9NBaexaQzgWCg,22882
15
- python_libei-0.7.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
16
- python_libei-0.7.0.dist-info/top_level.txt,sha256=_DQXzGjDsUBENI_cNkiOxPB4xi8coCbQS1lq18FMudQ,6
17
- python_libei-0.7.0.dist-info/RECORD,,