python-libei 0.6.1__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.
- {python_libei-0.6.1/src/python_libei.egg-info → python_libei-0.8.0}/PKG-INFO +3 -2
- {python_libei-0.6.1 → python_libei-0.8.0}/README.md +2 -1
- {python_libei-0.6.1 → python_libei-0.8.0}/pyproject.toml +1 -1
- {python_libei-0.6.1 → python_libei-0.8.0}/src/libei/__init__.py +1 -1
- {python_libei-0.6.1 → python_libei-0.8.0}/src/libei/portal.py +274 -23
- {python_libei-0.6.1 → python_libei-0.8.0/src/python_libei.egg-info}/PKG-INFO +3 -2
- {python_libei-0.6.1 → python_libei-0.8.0}/tests/test_abi.py +13 -2
- {python_libei-0.6.1 → python_libei-0.8.0}/tests/test_inputcapture.py +314 -0
- {python_libei-0.6.1 → python_libei-0.8.0}/tests/test_portal.py +11 -0
- {python_libei-0.6.1 → python_libei-0.8.0}/LICENSE +0 -0
- {python_libei-0.6.1 → python_libei-0.8.0}/setup.cfg +0 -0
- {python_libei-0.6.1 → python_libei-0.8.0}/src/libei/_capi/__init__.py +0 -0
- {python_libei-0.6.1 → python_libei-0.8.0}/src/libei/_capi/libei.py +0 -0
- {python_libei-0.6.1 → python_libei-0.8.0}/src/libei/_capi/libeis.py +0 -0
- {python_libei-0.6.1 → python_libei-0.8.0}/src/libei/_capi/liboeffis.py +0 -0
- {python_libei-0.6.1 → python_libei-0.8.0}/src/libei/_capi/loader.py +0 -0
- {python_libei-0.6.1 → python_libei-0.8.0}/src/libei/_cobject.py +0 -0
- {python_libei-0.6.1 → python_libei-0.8.0}/src/libei/ei.py +0 -0
- {python_libei-0.6.1 → python_libei-0.8.0}/src/libei/eis.py +0 -0
- {python_libei-0.6.1 → python_libei-0.8.0}/src/libei/oeffis.py +0 -0
- {python_libei-0.6.1 → python_libei-0.8.0}/src/libei/py.typed +0 -0
- {python_libei-0.6.1 → python_libei-0.8.0}/src/python_libei.egg-info/SOURCES.txt +0 -0
- {python_libei-0.6.1 → python_libei-0.8.0}/src/python_libei.egg-info/dependency_links.txt +0 -0
- {python_libei-0.6.1 → python_libei-0.8.0}/src/python_libei.egg-info/requires.txt +0 -0
- {python_libei-0.6.1 → python_libei-0.8.0}/src/python_libei.egg-info/top_level.txt +0 -0
- {python_libei-0.6.1 → python_libei-0.8.0}/tests/test_cobject.py +0 -0
- {python_libei-0.6.1 → python_libei-0.8.0}/tests/test_documentation_shape.py +0 -0
- {python_libei-0.6.1 → python_libei-0.8.0}/tests/test_documented_examples.py +0 -0
- {python_libei-0.6.1 → python_libei-0.8.0}/tests/test_ei_objects.py +0 -0
- {python_libei-0.6.1 → python_libei-0.8.0}/tests/test_eis_objects.py +0 -0
- {python_libei-0.6.1 → python_libei-0.8.0}/tests/test_event_accessors.py +0 -0
- {python_libei-0.6.1 → python_libei-0.8.0}/tests/test_integration_extras.py +0 -0
- {python_libei-0.6.1 → python_libei-0.8.0}/tests/test_integration_frame.py +0 -0
- {python_libei-0.6.1 → python_libei-0.8.0}/tests/test_integration_lifetime.py +0 -0
- {python_libei-0.6.1 → python_libei-0.8.0}/tests/test_integration_socketpair.py +0 -0
- {python_libei-0.6.1 → python_libei-0.8.0}/tests/test_loader.py +0 -0
- {python_libei-0.6.1 → 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.
|
|
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
|
+
[](https://github.com/ctrondlp/python-libei/blob/main/CONTRIBUTING.md)
|
|
43
44
|
[](https://github.com/ctrondlp/python-libei/actions/workflows/ci.yml)
|
|
44
45
|
[](https://pypi.org/project/python-libei/)
|
|
45
46
|
[](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.
|
|
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,5 +1,6 @@
|
|
|
1
1
|
# python-libei
|
|
2
2
|
|
|
3
|
+
[](https://github.com/ctrondlp/python-libei/blob/main/CONTRIBUTING.md)
|
|
3
4
|
[](https://github.com/ctrondlp/python-libei/actions/workflows/ci.yml)
|
|
4
5
|
[](https://pypi.org/project/python-libei/)
|
|
5
6
|
[](https://github.com/ctrondlp/python-libei/blob/main/LICENSE)
|
|
@@ -134,7 +135,7 @@ and why, is in
|
|
|
134
135
|
|
|
135
136
|
## Status
|
|
136
137
|
|
|
137
|
-
Beta (`0.
|
|
138
|
+
Beta (`0.8.0`), published on [PyPI](https://pypi.org/project/python-libei/)
|
|
138
139
|
since `0.1.0`, and **the API is not frozen** — expect renames before 1.0.
|
|
139
140
|
|
|
140
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
|
+
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"
|
|
@@ -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
|
|
1257
|
+
if _settled(): # a subscription outliving its wait fires twice
|
|
1053
1258
|
return
|
|
1054
1259
|
args = params.unpack()
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
+
[](https://github.com/ctrondlp/python-libei/blob/main/CONTRIBUTING.md)
|
|
43
44
|
[](https://github.com/ctrondlp/python-libei/actions/workflows/ci.yml)
|
|
44
45
|
[](https://pypi.org/project/python-libei/)
|
|
45
46
|
[](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.
|
|
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
|
|
@@ -97,7 +97,15 @@ def _declared(prefix: str) -> dict[str, tuple[tuple[type, ...], type | None]]:
|
|
|
97
97
|
|
|
98
98
|
|
|
99
99
|
def _installed_version(soname: str) -> tuple[int, ...] | None:
|
|
100
|
-
"""The version in the loaded library's file name (``libei.so.1.6.0``).
|
|
100
|
+
"""The version in the loaded library's file name (``libei.so.1.6.0``).
|
|
101
|
+
|
|
102
|
+
A micro of 900 or more is upstream's prerelease numbering for the *next*
|
|
103
|
+
minor -- Debian 13 ships 1.3.901, a snapshot on the way to 1.4 that
|
|
104
|
+
already exports the 1.4 symbols -- so it is normalized to that next
|
|
105
|
+
minor. Read literally, 1.3.901 "predates" every 1.4 marker while
|
|
106
|
+
carrying everything they mark, which failed the absent-function pin
|
|
107
|
+
below on a library whose only sin was being packaged mid-cycle.
|
|
108
|
+
"""
|
|
101
109
|
try:
|
|
102
110
|
maps = Path("/proc/self/maps").read_text()
|
|
103
111
|
except OSError:
|
|
@@ -106,7 +114,10 @@ def _installed_version(soname: str) -> tuple[int, ...] | None:
|
|
|
106
114
|
for line in maps.splitlines():
|
|
107
115
|
found = re.search(rf"/{re.escape(stem)}\.so\.(\d+\.\d+\.\d+)$", line)
|
|
108
116
|
if found:
|
|
109
|
-
|
|
117
|
+
major, minor, micro = _version(found.group(1))
|
|
118
|
+
if micro >= 900:
|
|
119
|
+
return (major, minor + 1, 0)
|
|
120
|
+
return (major, minor, micro)
|
|
110
121
|
return None
|
|
111
122
|
|
|
112
123
|
|
|
@@ -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,
|
|
@@ -83,6 +84,9 @@ class FakeInputCaptureConnection:
|
|
|
83
84
|
pending_activated: dict[str, Any] | None = None,
|
|
84
85
|
pending_deactivated: dict[str, Any] | None = None,
|
|
85
86
|
signal_session_handle: str | None = None,
|
|
87
|
+
pending_closed: bool = False,
|
|
88
|
+
pending_activated_raw: tuple[Any, ...] | None = None,
|
|
89
|
+
pending_deactivated_raw: tuple[Any, ...] | None = None,
|
|
86
90
|
) -> None:
|
|
87
91
|
self.session_handle = session_handle
|
|
88
92
|
self.signal_session_handle = signal_session_handle or session_handle
|
|
@@ -105,6 +109,13 @@ class FakeInputCaptureConnection:
|
|
|
105
109
|
self._pending_reply: tuple[str, int, dict[str, Any]] | None = None
|
|
106
110
|
self._pending_activated = pending_activated
|
|
107
111
|
self._pending_deactivated = pending_deactivated
|
|
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
|
|
108
119
|
# A permanent log, unlike _subscriptions: signal_unsubscribe removes
|
|
109
120
|
# an entry from that (correctly -- see its own docstring), so a test
|
|
110
121
|
# checking *what was subscribed to* after the fact needs a record
|
|
@@ -176,14 +187,31 @@ class FakeInputCaptureConnection:
|
|
|
176
187
|
self._pending_reply = None
|
|
177
188
|
_path, code, results = pending
|
|
178
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))
|
|
179
194
|
elif signal == "Activated" and self._pending_activated is not None:
|
|
180
195
|
payload = self._pending_activated
|
|
181
196
|
self._pending_activated = None
|
|
182
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
|
+
)
|
|
183
204
|
elif signal == "Deactivated" and self._pending_deactivated is not None:
|
|
184
205
|
payload = self._pending_deactivated
|
|
185
206
|
self._pending_deactivated = None
|
|
186
207
|
self._emit_session_signal(callback, "Deactivated", payload)
|
|
208
|
+
elif signal == "Closed" and self._pending_closed:
|
|
209
|
+
# Closed fires on the session object's own path, not the
|
|
210
|
+
# portal's -- the opposite of Activated/Deactivated above -- so
|
|
211
|
+
# it is only delivered to a subscription made there.
|
|
212
|
+
if path == self.session_handle:
|
|
213
|
+
self._pending_closed = False
|
|
214
|
+
callback(None, None, path, None, "Closed", FakeReply(({},)))
|
|
187
215
|
return self._next_subscription_id
|
|
188
216
|
|
|
189
217
|
def _emit_session_signal(
|
|
@@ -557,6 +585,166 @@ def test_wait_for_activation_tolerates_a_missing_barrier_id() -> None:
|
|
|
557
585
|
assert activation.barrier_id is None
|
|
558
586
|
|
|
559
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
|
+
|
|
560
748
|
def test_wait_for_activation_times_out_with_no_signal() -> None:
|
|
561
749
|
connection = FakeInputCaptureConnection()
|
|
562
750
|
with install_fake_gi(connection):
|
|
@@ -589,6 +777,44 @@ def test_wait_for_activation_does_not_double_remove_the_timeout_source_on_timeou
|
|
|
589
777
|
source_remove.assert_not_called()
|
|
590
778
|
|
|
591
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
|
+
|
|
592
818
|
def test_wait_for_activation_subscribes_on_the_portal_not_the_session() -> None:
|
|
593
819
|
# The bug that made this whole feature look like a compositor gap: the
|
|
594
820
|
# portal emits Activated on its own object, never on the session's, so
|
|
@@ -770,3 +996,91 @@ def test_a_failed_session_close_does_not_propagate() -> None:
|
|
|
770
996
|
with install_fake_gi(connection):
|
|
771
997
|
session = portal.InputCaptureSession.negotiate(connection=connection)
|
|
772
998
|
session.close() # must not raise
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
# -- the session Closed signal ------------------------------------------------
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
def test_a_closed_session_ends_wait_for_activation_rather_than_hanging() -> None:
|
|
1005
|
+
# The compositor-disconnect case: Session.Closed fires when the portal
|
|
1006
|
+
# or compositor ends the session from its side. Before the Closed
|
|
1007
|
+
# subscription existed, a wait with timeout=None on such a session
|
|
1008
|
+
# blocked forever -- indistinguishable from a barrier nobody had
|
|
1009
|
+
# crossed yet.
|
|
1010
|
+
connection = FakeInputCaptureConnection(pending_closed=True)
|
|
1011
|
+
with install_fake_gi(connection):
|
|
1012
|
+
session = portal.InputCaptureSession.negotiate(connection=connection)
|
|
1013
|
+
with pytest.raises(portal.PortalSessionClosedError) as excinfo:
|
|
1014
|
+
session.wait_for_activation(timeout=None)
|
|
1015
|
+
assert excinfo.value.step == "Activated"
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
def test_a_closed_session_ends_wait_for_deactivation_too() -> None:
|
|
1019
|
+
connection = FakeInputCaptureConnection(pending_closed=True)
|
|
1020
|
+
with install_fake_gi(connection):
|
|
1021
|
+
session = portal.InputCaptureSession.negotiate(connection=connection)
|
|
1022
|
+
with pytest.raises(portal.PortalSessionClosedError) as excinfo:
|
|
1023
|
+
session.wait_for_deactivation(timeout=None)
|
|
1024
|
+
assert excinfo.value.step == "Deactivated"
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def test_closed_is_subscribed_on_the_session_path_not_the_portal() -> None:
|
|
1028
|
+
# The mirror image of the Activated-path bug: Closed is the one signal
|
|
1029
|
+
# that really does fire on the session object's own path, so the
|
|
1030
|
+
# subscription has to go there.
|
|
1031
|
+
connection = FakeInputCaptureConnection()
|
|
1032
|
+
with install_fake_gi(connection):
|
|
1033
|
+
session = portal.InputCaptureSession.negotiate(connection=connection)
|
|
1034
|
+
with pytest.raises(portal.PortalTimeoutError):
|
|
1035
|
+
session.wait_for_activation(timeout=0.01)
|
|
1036
|
+
subscribed = {
|
|
1037
|
+
key[2] for key in connection.signal_subscriptions if key[1] == "Closed"
|
|
1038
|
+
}
|
|
1039
|
+
assert subscribed == {connection.session_handle}
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
def test_every_wait_subscription_is_unsubscribed_again() -> None:
|
|
1043
|
+
# Both the signal's own subscription and the Closed one: a leak here
|
|
1044
|
+
# means a long-running capture loop accumulates one dead subscription
|
|
1045
|
+
# per activation cycle.
|
|
1046
|
+
connection = FakeInputCaptureConnection(pending_activated={"activation_id": 1})
|
|
1047
|
+
with install_fake_gi(connection):
|
|
1048
|
+
session = portal.InputCaptureSession.negotiate(connection=connection)
|
|
1049
|
+
session.wait_for_activation(timeout=1.0)
|
|
1050
|
+
live = [k for k in connection._subscriptions if k[1] in ("Activated", "Closed")]
|
|
1051
|
+
assert live == []
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
def test_a_session_closed_mid_wait_still_cleans_up_its_subscriptions() -> None:
|
|
1055
|
+
connection = FakeInputCaptureConnection(pending_closed=True)
|
|
1056
|
+
with install_fake_gi(connection):
|
|
1057
|
+
session = portal.InputCaptureSession.negotiate(connection=connection)
|
|
1058
|
+
with pytest.raises(portal.PortalSessionClosedError):
|
|
1059
|
+
session.wait_for_activation(timeout=None)
|
|
1060
|
+
live = [k for k in connection._subscriptions if k[1] in ("Activated", "Closed")]
|
|
1061
|
+
assert live == []
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
def test_repeated_activation_cycles_each_get_their_own_signal() -> None:
|
|
1065
|
+
# A capture loop waits, releases, waits again -- each wait is its own
|
|
1066
|
+
# subscription, delivered its own payload, with nothing carried over.
|
|
1067
|
+
connection = FakeInputCaptureConnection(pending_activated={"activation_id": 1})
|
|
1068
|
+
with install_fake_gi(connection):
|
|
1069
|
+
session = portal.InputCaptureSession.negotiate(connection=connection)
|
|
1070
|
+
first = session.wait_for_activation(timeout=1.0)
|
|
1071
|
+
connection._pending_activated = {"activation_id": 2}
|
|
1072
|
+
second = session.wait_for_activation(timeout=1.0)
|
|
1073
|
+
assert (first.activation_id, second.activation_id) == (1, 2)
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
def test_an_activation_payload_without_an_id_defaults_to_zero() -> None:
|
|
1077
|
+
# The spec says activation_id is always sent; a portal that omits it
|
|
1078
|
+
# anyway gets the same 0 a compositor that "could not determine" sends,
|
|
1079
|
+
# rather than a KeyError mid-wait.
|
|
1080
|
+
connection = FakeInputCaptureConnection(pending_activated={})
|
|
1081
|
+
with install_fake_gi(connection):
|
|
1082
|
+
session = portal.InputCaptureSession.negotiate(connection=connection)
|
|
1083
|
+
activation = session.wait_for_activation(timeout=1.0)
|
|
1084
|
+
assert activation == portal.Activation(
|
|
1085
|
+
activation_id=0, cursor_position=None, barrier_id=None
|
|
1086
|
+
)
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|