python-libei 0.5.1__py3-none-any.whl → 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
libei/__init__.py CHANGED
@@ -31,6 +31,6 @@ the full breakdown, including which features need which libei version.
31
31
  Beta: the API is not frozen.
32
32
  """
33
33
 
34
- __version__ = "0.5.1"
34
+ __version__ = "0.6.0"
35
35
 
36
36
  __all__ = ["__version__"]
libei/_capi/__init__.py CHANGED
@@ -1,5 +1,8 @@
1
- """Low-level ctypes bindings. Not part of the public API -- use
2
- ``libei.ei``, ``libei.eis`` and ``libei.oeffis`` instead."""
1
+ """Low-level ctypes bindings.
2
+
3
+ Not part of the public API -- use ``libei.ei``, ``libei.eis`` and
4
+ ``libei.oeffis`` instead.
5
+ """
3
6
 
4
7
  from . import libei, libeis, liboeffis
5
8
 
libei/_capi/loader.py CHANGED
@@ -25,12 +25,18 @@ class LazyLibrary:
25
25
  """A ctypes.CDLL that only opens the library on first real use."""
26
26
 
27
27
  def __init__(self, soname: str) -> None:
28
+ """Remember the soname; nothing is opened until first use."""
28
29
  self._soname = soname
29
30
  self._lib: ctypes.CDLL | None = None
30
31
  self._load_error: OSError | None = None
31
32
  self._lock = threading.Lock()
32
33
 
33
34
  def _ensure_loaded(self) -> ctypes.CDLL:
35
+ """Return the opened library, opening it on first call.
36
+
37
+ Raises LibraryNotFoundError on every later call too after a failure:
38
+ the failed load is cached rather than retried.
39
+ """
34
40
  # Double-checked: the unlocked read is the fast path taken by every
35
41
  # call after the first, and the repeated check inside the lock is
36
42
  # what makes it safe -- two threads can both fall through the first
@@ -91,6 +97,7 @@ class LazyLibrary:
91
97
  cache: dict[str, Any] = {}
92
98
 
93
99
  def call(*args: Any) -> Any:
100
+ """Resolve the C function on first call, then pass straight through."""
94
101
  # Resolution happens here, on first call, not at bind time --
95
102
  # that is the whole point of this module (see its docstring).
96
103
  bound = cache.get("f")
libei/_cobject.py CHANGED
@@ -66,6 +66,7 @@ class CObject:
66
66
  _instances_lock: ClassVar[threading.RLock]
67
67
 
68
68
  def __init_subclass__(cls, **kwargs: Any) -> None:
69
+ """Give each wrapper hierarchy one identity cache, shared by subclasses."""
69
70
  super().__init_subclass__(**kwargs)
70
71
  # One cache per wrapper *hierarchy*, not per class. Only a root
71
72
  # wrapper class -- one whose only CObject ancestor is CObject
@@ -98,6 +99,7 @@ class CObject:
98
99
  cls._instances_lock = threading.RLock()
99
100
 
100
101
  def __init__(self, pointer: int, *, _adopt: bool = False) -> None:
102
+ """Take a reference on a non-NULL pointer and cache this wrapper for it."""
101
103
  if not pointer:
102
104
  raise ValueError(f"{type(self).__name__} cannot wrap a NULL pointer")
103
105
  self._pointer = pointer
@@ -121,6 +123,7 @@ class CObject:
121
123
 
122
124
  @property
123
125
  def _as_parameter_(self) -> int:
126
+ """The raw pointer ctypes passes to C -- an error once it is released."""
124
127
  if self._pointer == 0:
125
128
  raise RuntimeError(
126
129
  f"{type(self).__name__} has already been released; "
@@ -159,6 +162,7 @@ class CObject:
159
162
 
160
163
  @classmethod
161
164
  def _get_or_create(cls: type[T], pointer: int | None, *, adopt: bool) -> T | None:
165
+ """The wrapper already caching this pointer, or a new one; None for NULL."""
162
166
  if not pointer:
163
167
  return None
164
168
  if not cls._wrappable:
@@ -232,6 +236,7 @@ class CObject:
232
236
  return cls._get_or_create(pointer, adopt=True)
233
237
 
234
238
  def __eq__(self, other: object) -> bool:
239
+ """Equal when the types match and the wrapped pointers are the same."""
235
240
  if not isinstance(other, CObject):
236
241
  return NotImplemented
237
242
  if type(self) is not type(other):
@@ -245,6 +250,7 @@ class CObject:
245
250
  return self._pointer == other._pointer
246
251
 
247
252
  def __hash__(self) -> int:
253
+ """Stable for the object's life: the original pointer, not the live one."""
248
254
  # Deliberately keyed on _hash_key, not _pointer: release() zeroes
249
255
  # _pointer, and an object whose hash changes mid-life vanishes
250
256
  # from any set or dict it was placed in. Two wrappers can share a
libei/ei.py CHANGED
@@ -67,6 +67,7 @@ _emulating_sequence = itertools.count(1)
67
67
 
68
68
 
69
69
  def _next_emulating_sequence() -> int:
70
+ """The next emulating-event sequence number: 32-bit, and never 0."""
70
71
  # Masked into uint32 to match the C parameter. libei asks callers to
71
72
  # keep wraparound detection "reasonable"; skipping 0 keeps the value
72
73
  # away from anything that might read as unset.
@@ -87,6 +88,7 @@ class Error(Exception):
87
88
  """
88
89
 
89
90
  def __init__(self, message: str, errno: int | None = None) -> None:
91
+ """Record the failure message and, where libei reported one, errno."""
90
92
  super().__init__(message)
91
93
  self.message = message
92
94
  self.errno = errno
@@ -191,6 +193,8 @@ class KeymapType(enum.IntEnum):
191
193
 
192
194
 
193
195
  class _LogPriority(enum.IntEnum):
196
+ """The log levels, as the integers the C library passes to its handler."""
197
+
194
198
  DEBUG = 10
195
199
  INFO = 20
196
200
  WARNING = 30
@@ -199,6 +203,12 @@ class _LogPriority(enum.IntEnum):
199
203
 
200
204
  @dataclasses.dataclass(frozen=True, slots=True)
201
205
  class XkbModifiersEvent:
206
+ """XKB modifier state from a KEYBOARD_MODIFIERS event.
207
+
208
+ ``depressed``/``latched``/``locked`` are XKB's own mod-state bitmasks;
209
+ ``group`` is the active keyboard layout group.
210
+ """
211
+
202
212
  depressed: int
203
213
  latched: int
204
214
  locked: int
@@ -207,48 +217,76 @@ class XkbModifiersEvent:
207
217
 
208
218
  @dataclasses.dataclass(frozen=True, slots=True)
209
219
  class KeyEvent:
220
+ """Key code and press state from a KEYBOARD_KEY event.
221
+
222
+ ``key`` is a Linux ``KEY_*`` code, the same numbering
223
+ :meth:`Device.keyboard_key` sends.
224
+ """
225
+
210
226
  key: int
211
227
  is_press: bool
212
228
 
213
229
 
214
230
  @dataclasses.dataclass(frozen=True, slots=True)
215
231
  class ButtonEvent:
232
+ """Button code and press state from a BUTTON_BUTTON event.
233
+
234
+ ``button`` is a Linux ``BTN_*`` code, the same numbering
235
+ :meth:`Device.button` sends.
236
+ """
237
+
216
238
  button: int
217
239
  is_press: bool
218
240
 
219
241
 
220
242
  @dataclasses.dataclass(frozen=True, slots=True)
221
243
  class PointerEvent:
244
+ """Relative motion deltas, in logical pixels, from a POINTER_MOTION event."""
245
+
222
246
  dx: float
223
247
  dy: float
224
248
 
225
249
 
226
250
  @dataclasses.dataclass(frozen=True, slots=True)
227
251
  class PointerAbsoluteEvent:
252
+ """Absolute position from a POINTER_MOTION_ABSOLUTE event.
253
+
254
+ In the logical pixel space of the :class:`Region` the emitting device
255
+ covers -- see :meth:`Event.pointer_absolute_event`.
256
+ """
257
+
228
258
  x: float
229
259
  y: float
230
260
 
231
261
 
232
262
  @dataclasses.dataclass(frozen=True, slots=True)
233
263
  class ScrollEvent:
264
+ """Smooth scroll deltas from a SCROLL_DELTA event."""
265
+
234
266
  dx: float
235
267
  dy: float
236
268
 
237
269
 
238
270
  @dataclasses.dataclass(frozen=True, slots=True)
239
271
  class ScrollDiscreteEvent:
272
+ """Detent scroll deltas (120 per detent) from a SCROLL_DISCRETE event."""
273
+
240
274
  dx: int
241
275
  dy: int
242
276
 
243
277
 
244
278
  @dataclasses.dataclass(frozen=True, slots=True)
245
279
  class ScrollStopEvent:
280
+ """Which axes stopped scrolling, from a SCROLL_STOP/SCROLL_CANCEL event."""
281
+
246
282
  stop_x: bool
247
283
  stop_y: bool
248
284
 
249
285
 
250
286
  @dataclasses.dataclass(frozen=True, slots=True)
251
287
  class TouchEvent:
288
+ """Touch id and position from a TOUCH_DOWN or TOUCH_MOTION event."""
289
+
252
290
  touchid: int
253
291
  x: float
254
292
  y: float
@@ -256,17 +294,26 @@ class TouchEvent:
256
294
 
257
295
  @dataclasses.dataclass(frozen=True, slots=True)
258
296
  class TouchUpEvent:
297
+ """Touch id and cancellation flag from a TOUCH_UP event.
298
+
299
+ See :meth:`Event.touch_up_event` for when ``is_cancel`` is trustworthy.
300
+ """
301
+
259
302
  touchid: int
260
303
  is_cancel: bool
261
304
 
262
305
 
263
306
  @dataclasses.dataclass(frozen=True, slots=True)
264
307
  class TextUtf8Event:
308
+ """UTF-8 text carried by a TEXT_UTF8 event."""
309
+
265
310
  text: str
266
311
 
267
312
 
268
313
  @dataclasses.dataclass(frozen=True, slots=True)
269
314
  class TextKeysymEvent:
315
+ """Keysym and press state from a TEXT_KEYSYM event."""
316
+
270
317
  keysym: int
271
318
  is_press: bool
272
319
 
@@ -285,6 +332,7 @@ class Region(CObject):
285
332
  _unref_func = staticmethod(_capi.libei.region_unref)
286
333
 
287
334
  def __repr__(self) -> str:
335
+ """The size and position, as WxH+X+Y."""
288
336
  w, h = self.dimension
289
337
  x, y = self.position
290
338
  return f"<Region {w}x{h}+{x}+{y}>"
@@ -466,6 +514,7 @@ class Device(CObject):
466
514
  _unref_func = staticmethod(_capi.libei.device_unref)
467
515
 
468
516
  def __repr__(self) -> str:
517
+ """Name, device type and capabilities -- what tells two devices apart."""
469
518
  caps = "|".join(c.name or str(c.value) for c in self.capabilities)
470
519
  return f"<Device {self.name!r} {self.device_type.name} {caps}>"
471
520
 
@@ -545,9 +594,10 @@ class Device(CObject):
545
594
  return self
546
595
 
547
596
  def frame(self, timestamp: int | None = None) -> Device:
548
- """Commit the events queued since the last frame as one logical
549
- hardware event. ``timestamp`` defaults to the context's current
550
- time."""
597
+ """Commit the events queued since the last frame as one logical hardware event.
598
+
599
+ ``timestamp`` defaults to the context's current time.
600
+ """
551
601
  if timestamp is None:
552
602
  timestamp = _capi.libei.now(_capi.libei.device_get_context(self))
553
603
  _capi.libei.device_frame(self, timestamp)
@@ -564,8 +614,10 @@ class Device(CObject):
564
614
  return self
565
615
 
566
616
  def button(self, button: int, is_press: bool) -> Device:
567
- """Queue a button press or release. ``button`` is a Linux
568
- ``BTN_*`` code (e.g. ``0x110`` for ``BTN_LEFT``)."""
617
+ """Queue a button press or release.
618
+
619
+ ``button`` is a Linux ``BTN_*`` code (e.g. ``0x110`` for ``BTN_LEFT``).
620
+ """
569
621
  _capi.libei.device_button_button(self, button, is_press)
570
622
  return self
571
623
 
@@ -645,6 +697,7 @@ class Seat(CObject):
645
697
  _unref_func = staticmethod(_capi.libei.seat_unref)
646
698
 
647
699
  def __repr__(self) -> str:
700
+ """The seat's name and capabilities."""
648
701
  caps = "|".join(c.name or str(c.value) for c in self.capabilities)
649
702
  return f"<Seat {self.name!r} {caps}>"
650
703
 
@@ -727,6 +780,7 @@ class Ping(CObject):
727
780
  _unref_func = staticmethod(_capi.libei.ping_unref)
728
781
 
729
782
  def __repr__(self) -> str:
783
+ """The ping's id, which is all a ping carries."""
730
784
  return f"<Ping {self.id}>"
731
785
 
732
786
  @property
@@ -754,14 +808,18 @@ class Event(CObject):
754
808
  _unref_func = staticmethod(_capi.libei.event_unref)
755
809
 
756
810
  def __repr__(self) -> str:
811
+ """The event type by name, or the raw value for one we do not model."""
757
812
  event_type = self.event_type
758
813
  label = event_type.name if isinstance(event_type, EventType) else event_type
759
814
  return f"<Event {label}>"
760
815
 
761
816
  @property
762
817
  def event_type(self) -> EventType | int:
763
- """The event's type, or a raw int for a value newer than this
764
- package's :class:`EventType` table -- see its docstring."""
818
+ """The event's type.
819
+
820
+ Returns a raw int for a value newer than this package's
821
+ :class:`EventType` table -- see its docstring.
822
+ """
765
823
  raw = _capi.libei.event_get_type(self)
766
824
  try:
767
825
  return EventType(raw)
@@ -958,6 +1016,11 @@ class Event(CObject):
958
1016
 
959
1017
 
960
1018
  def _log_callback(_ei: int, priority: int, message: bytes, _context: int) -> None:
1019
+ """Forward libei's log lines into the logging module.
1020
+
1021
+ Runs inside a ctypes callback, where an exception is printed to stderr
1022
+ and then dropped, so every lookup below falls back rather than raising.
1023
+ """
961
1024
  # Look up the raw int, not _LogPriority(priority): constructing the
962
1025
  # enum from an unrecognized value raises ValueError immediately, which
963
1026
  # would happen *before* .get()'s default ever gets a chance to apply
@@ -1009,11 +1072,14 @@ class Context(CObject):
1009
1072
  _wrappable = False
1010
1073
 
1011
1074
  def __init__(self, pointer: int, *, _adopt: bool = False) -> None:
1012
- # _adopt is accepted and forwarded for signature consistency with
1013
- # CObject, but with _wrappable = False, _get_or_create() never
1014
- # actually reaches this constructor -- Context (and Sender/
1015
- # Receiver) are always built directly via cls(cls._new()) in
1016
- # create_for_fd()/create_for_socket().
1075
+ """Wrap a freshly created ``struct ei *`` and arm its log handler.
1076
+
1077
+ _adopt is accepted and forwarded for signature consistency with
1078
+ CObject, but with _wrappable = False, _get_or_create() never
1079
+ actually reaches this constructor -- Context (and Sender/
1080
+ Receiver) are always built directly via cls(cls._new()) in
1081
+ create_for_fd()/create_for_socket().
1082
+ """
1017
1083
  super().__init__(pointer, _adopt=_adopt)
1018
1084
  self._name: str | None = None
1019
1085
  _capi.libei.log_set_handler(self, _log_handler)
@@ -1081,7 +1147,8 @@ class Context(CObject):
1081
1147
  """Use an already-connected socket as the transport.
1082
1148
 
1083
1149
  libei takes ownership of a raw int fd and closes it itself; a file
1084
- object is duplicated first, so the caller's own object stays valid."""
1150
+ object is duplicated first, so the caller's own object stays valid.
1151
+ """
1085
1152
  # ei_setup_backend_fd() takes ownership of the fd and will close it
1086
1153
  # itself. A raw int is assumed to already be one the caller is
1087
1154
  # handing off (matching what eis.Eis.add_client()/oeffis.eis_fd
@@ -1099,7 +1166,8 @@ class Context(CObject):
1099
1166
  """Connect to an EIS socket by path.
1100
1167
 
1101
1168
  ``None`` uses ``$LIBEI_SOCKET``; a relative path is resolved
1102
- against ``$XDG_RUNTIME_DIR``."""
1169
+ against ``$XDG_RUNTIME_DIR``.
1170
+ """
1103
1171
  encoded = os.fspath(path).encode("utf-8") if path else None
1104
1172
  err = _capi.libei.setup_backend_socket(self, encoded)
1105
1173
  if err < 0:
@@ -1162,7 +1230,8 @@ class Context(CObject):
1162
1230
  """Read from the connection and queue any events that arrive.
1163
1231
 
1164
1232
  Call this before iterating :attr:`events`, which only drains what
1165
- is already queued."""
1233
+ is already queued.
1234
+ """
1166
1235
  _capi.libei.dispatch(self)
1167
1236
 
1168
1237
 
@@ -1171,6 +1240,7 @@ class Sender(Context):
1171
1240
 
1172
1241
  @classmethod
1173
1242
  def _new(cls) -> int:
1243
+ """A new sender from the C library, or an error if it returned NULL."""
1174
1244
  pointer = _capi.libei.new_sender(c_void_p(None))
1175
1245
  if not pointer:
1176
1246
  raise Error("ei_new_sender() returned NULL")
@@ -1194,6 +1264,7 @@ class Receiver(Context):
1194
1264
 
1195
1265
  @classmethod
1196
1266
  def _new(cls) -> int:
1267
+ """A new receiver from the C library, or an error if it returned NULL."""
1197
1268
  pointer = _capi.libei.new_receiver(c_void_p(None))
1198
1269
  if not pointer:
1199
1270
  raise Error("ei_new_receiver() returned NULL")
@@ -1224,6 +1295,10 @@ __all__ = [
1224
1295
  "KeyEvent",
1225
1296
  "Keymap",
1226
1297
  "KeymapType",
1298
+ # Imported rather than defined here: every call bound through
1299
+ # _capi.libei can raise it, so a caller importing from this module
1300
+ # catches it from here too -- see docs/troubleshooting.md.
1301
+ "LibraryNotFoundError",
1227
1302
  "Ping",
1228
1303
  "PointerAbsoluteEvent",
1229
1304
  "PointerEvent",
libei/eis.py CHANGED
@@ -55,6 +55,7 @@ class Error(Exception):
55
55
  """
56
56
 
57
57
  def __init__(self, message: str, errno: int | None = None) -> None:
58
+ """Record the failure message and, where libeis reported one, errno."""
58
59
  super().__init__(message)
59
60
  self.message = message
60
61
  self.errno = errno
@@ -158,6 +159,8 @@ class Flag(enum.IntEnum):
158
159
 
159
160
 
160
161
  class _LogPriority(enum.IntEnum):
162
+ """The log levels, as the integers libeis passes to its log handler."""
163
+
161
164
  DEBUG = 10
162
165
  INFO = 20
163
166
  WARNING = 30
@@ -166,48 +169,75 @@ class _LogPriority(enum.IntEnum):
166
169
 
167
170
  @dataclasses.dataclass(frozen=True, slots=True)
168
171
  class KeyEvent:
172
+ """Key code and press state received on a KEYBOARD_KEY event.
173
+
174
+ ``key`` is a Linux ``KEY_*`` code, as sent by the client's
175
+ ``ei.Device.keyboard_key``.
176
+ """
177
+
169
178
  key: int
170
179
  is_press: bool
171
180
 
172
181
 
173
182
  @dataclasses.dataclass(frozen=True, slots=True)
174
183
  class ButtonEvent:
184
+ """Button code and press state received on a BUTTON_BUTTON event.
185
+
186
+ ``button`` is a Linux ``BTN_*`` code, as sent by the client's
187
+ ``ei.Device.button``.
188
+ """
189
+
175
190
  button: int
176
191
  is_press: bool
177
192
 
178
193
 
179
194
  @dataclasses.dataclass(frozen=True, slots=True)
180
195
  class PointerEvent:
196
+ """Relative motion deltas, in logical pixels, from a POINTER_MOTION event."""
197
+
181
198
  dx: float
182
199
  dy: float
183
200
 
184
201
 
185
202
  @dataclasses.dataclass(frozen=True, slots=True)
186
203
  class PointerAbsoluteEvent:
204
+ """Absolute position from a POINTER_MOTION_ABSOLUTE event.
205
+
206
+ In the logical pixel space of the region the sending device covers.
207
+ """
208
+
187
209
  x: float
188
210
  y: float
189
211
 
190
212
 
191
213
  @dataclasses.dataclass(frozen=True, slots=True)
192
214
  class ScrollEvent:
215
+ """Smooth scroll deltas from a SCROLL_DELTA event."""
216
+
193
217
  dx: float
194
218
  dy: float
195
219
 
196
220
 
197
221
  @dataclasses.dataclass(frozen=True, slots=True)
198
222
  class ScrollDiscreteEvent:
223
+ """Detent scroll deltas (120 per detent) from a SCROLL_DISCRETE event."""
224
+
199
225
  dx: int
200
226
  dy: int
201
227
 
202
228
 
203
229
  @dataclasses.dataclass(frozen=True, slots=True)
204
230
  class ScrollStopEvent:
231
+ """Which axes stopped scrolling, from a SCROLL_STOP/SCROLL_CANCEL event."""
232
+
205
233
  stop_x: bool
206
234
  stop_y: bool
207
235
 
208
236
 
209
237
  @dataclasses.dataclass(frozen=True, slots=True)
210
238
  class TouchEvent:
239
+ """Touch id and position from a TOUCH_DOWN or TOUCH_MOTION event."""
240
+
211
241
  touchid: int
212
242
  x: float
213
243
  y: float
@@ -215,17 +245,23 @@ class TouchEvent:
215
245
 
216
246
  @dataclasses.dataclass(frozen=True, slots=True)
217
247
  class TouchUpEvent:
248
+ """Touch id and cancellation flag from a TOUCH_UP event."""
249
+
218
250
  touchid: int
219
251
  is_cancel: bool
220
252
 
221
253
 
222
254
  @dataclasses.dataclass(frozen=True, slots=True)
223
255
  class TextUtf8Event:
256
+ """UTF-8 text carried by a TEXT_UTF8 event."""
257
+
224
258
  text: str
225
259
 
226
260
 
227
261
  @dataclasses.dataclass(frozen=True, slots=True)
228
262
  class TextKeysymEvent:
263
+ """Keysym and press state from a TEXT_KEYSYM event."""
264
+
229
265
  keysym: int
230
266
  is_press: bool
231
267
 
@@ -422,6 +458,7 @@ class Device(CObject):
422
458
  _unref_func = staticmethod(_capi.libeis.device_unref)
423
459
 
424
460
  def __repr__(self) -> str:
461
+ """Name, device type and capabilities -- what tells two devices apart."""
425
462
  caps = "|".join(c.name or str(c.value) for c in self.capabilities)
426
463
  return f"<Device {self.name!r} {self.device_type.name} {caps}>"
427
464
 
@@ -576,8 +613,10 @@ class Device(CObject):
576
613
  return self
577
614
 
578
615
  def frame(self, timestamp: int | None = None) -> Device:
579
- """Commit the events queued since the last frame as one logical
580
- hardware event. ``timestamp`` defaults to the context's current time."""
616
+ """Commit the events queued since the last frame as one logical hardware event.
617
+
618
+ ``timestamp`` defaults to the context's current time.
619
+ """
581
620
  if timestamp is None:
582
621
  timestamp = _capi.libeis.now(_capi.libeis.device_get_context(self))
583
622
  _capi.libeis.device_frame(self, timestamp)
@@ -673,6 +712,7 @@ class Seat(CObject):
673
712
  _unref_func = staticmethod(_capi.libeis.seat_unref)
674
713
 
675
714
  def __repr__(self) -> str:
715
+ """The seat's name and capabilities."""
676
716
  caps = "|".join(c.name or str(c.value) for c in self.capabilities)
677
717
  return f"<Seat {self.name!r} {caps}>"
678
718
 
@@ -734,6 +774,7 @@ class Client(CObject):
734
774
  _unref_func = staticmethod(_capi.libeis.client_unref)
735
775
 
736
776
  def __repr__(self) -> str:
777
+ """The client's name and which side of the protocol it is on."""
737
778
  return f"<Client {self.name!r} sender={self.is_sender}>"
738
779
 
739
780
  @property
@@ -804,6 +845,7 @@ class Ping(CObject):
804
845
  _unref_func = staticmethod(_capi.libeis.ping_unref)
805
846
 
806
847
  def __repr__(self) -> str:
848
+ """The ping's id, which is all a ping carries."""
807
849
  return f"<Ping {self.id}>"
808
850
 
809
851
  @property
@@ -831,14 +873,18 @@ class Event(CObject):
831
873
  _unref_func = staticmethod(_capi.libeis.event_unref)
832
874
 
833
875
  def __repr__(self) -> str:
876
+ """The event type by name, or the raw value for one we do not model."""
834
877
  event_type = self.event_type
835
878
  label = event_type.name if isinstance(event_type, EventType) else event_type
836
879
  return f"<Event {label}>"
837
880
 
838
881
  @property
839
882
  def event_type(self) -> EventType | int:
840
- """The event's type, or a raw int for a value newer than this
841
- package's :class:`EventType` table -- see its docstring."""
883
+ """The event's type.
884
+
885
+ Returns a raw int for a value newer than this package's
886
+ :class:`EventType` table -- see its docstring.
887
+ """
842
888
  raw = _capi.libeis.event_get_type(self)
843
889
  try:
844
890
  return EventType(raw)
@@ -1040,6 +1086,11 @@ class Event(CObject):
1040
1086
 
1041
1087
 
1042
1088
  def _log_callback(_eis: int, priority: int, message: bytes, _context: int) -> None:
1089
+ """Forward libeis's log lines into the logging module.
1090
+
1091
+ The same constraint as ei.py's callback of that name: it runs inside a
1092
+ ctypes callback, so it falls back rather than raising.
1093
+ """
1043
1094
  # See ei.py's _log_callback: look up the raw int, not
1044
1095
  # _LogPriority(priority), which would raise ValueError before .get()'s
1045
1096
  # default could apply -- silently, since this runs inside a ctypes
@@ -1069,10 +1120,13 @@ class Eis(CObject):
1069
1120
  _wrappable = False
1070
1121
 
1071
1122
  def __init__(self, pointer: int, *, _adopt: bool = False) -> None:
1072
- # _adopt is accepted and forwarded for signature consistency with
1073
- # CObject, but with _wrappable = False, _get_or_create() never
1074
- # actually reaches this constructor -- Eis is always built directly
1075
- # via cls(cls._new()) in create_for_fd().
1123
+ """Wrap a freshly created ``struct eis *`` and arm its log handler.
1124
+
1125
+ _adopt is accepted and forwarded for signature consistency with
1126
+ CObject, but with _wrappable = False, _get_or_create() never
1127
+ actually reaches this constructor -- Eis is always built directly
1128
+ via cls(cls._new()) in create_for_fd().
1129
+ """
1076
1130
  super().__init__(pointer, _adopt=_adopt)
1077
1131
  _capi.libeis.log_set_handler(self, _log_handler)
1078
1132
  _capi.libeis.log_set_priority(self, _LogPriority.DEBUG)
@@ -1146,7 +1200,8 @@ class Eis(CObject):
1146
1200
  """Read from the connection and queue any events that arrive.
1147
1201
 
1148
1202
  Call this before iterating :attr:`events`, which only drains what
1149
- is already queued."""
1203
+ is already queued.
1204
+ """
1150
1205
  _capi.libeis.dispatch(self)
1151
1206
 
1152
1207
  def add_client(self) -> int:
@@ -1165,6 +1220,7 @@ class Eis(CObject):
1165
1220
 
1166
1221
  @classmethod
1167
1222
  def _new(cls) -> int:
1223
+ """A new server from the C library, or an error if it returned NULL."""
1168
1224
  pointer = _capi.libeis.new(c_void_p(None))
1169
1225
  if not pointer:
1170
1226
  raise Error("eis_new() returned NULL")
@@ -1172,14 +1228,16 @@ class Eis(CObject):
1172
1228
 
1173
1229
  @classmethod
1174
1230
  def create_for_fd(cls, flags: Sequence[Flag] = ()) -> Eis:
1175
- """Create a server using the fd backend -- the one real compositors
1176
- use, since it keeps each client's fd private rather than exposing a
1177
- connectable socket path. Call :meth:`add_client` once per
1178
- connection you want to accept.
1231
+ """Create a server using the fd backend.
1232
+
1233
+ The one real compositors use, since it keeps each client's fd
1234
+ private rather than exposing a connectable socket path. Call
1235
+ :meth:`add_client` once per connection you want to accept.
1179
1236
 
1180
1237
  ``flags`` are applied here rather than left to the caller because
1181
1238
  :meth:`set_flag` has to run before the backend is set up, and this
1182
- method does both."""
1239
+ method does both.
1240
+ """
1183
1241
  server = cls(cls._new())
1184
1242
  for flag in flags:
1185
1243
  server.set_flag(flag)
@@ -1190,9 +1248,12 @@ class Eis(CObject):
1190
1248
 
1191
1249
  @classmethod
1192
1250
  def create_for_socket(cls, path: Path, flags: Sequence[Flag] = ()) -> Eis:
1193
- """Create a server listening on a Unix socket, as a compositor
1194
- would (this is the path a real ``ei_setup_backend_socket()`` client
1195
- connects to). See :meth:`create_for_fd` on ``flags``."""
1251
+ """Create a server listening on a Unix socket.
1252
+
1253
+ As a compositor would (this is the path a real
1254
+ ``ei_setup_backend_socket()`` client connects to). See
1255
+ :meth:`create_for_fd` on ``flags``.
1256
+ """
1196
1257
  server = cls(cls._new())
1197
1258
  for flag in flags:
1198
1259
  server.set_flag(flag)
@@ -1217,6 +1278,10 @@ __all__ = [
1217
1278
  "KeyEvent",
1218
1279
  "Keymap",
1219
1280
  "KeymapType",
1281
+ # Imported rather than defined here: every call bound through
1282
+ # _capi.libeis can raise it, so a caller importing from this module
1283
+ # catches it from here too -- see docs/troubleshooting.md.
1284
+ "LibraryNotFoundError",
1220
1285
  "Ping",
1221
1286
  "PointerAbsoluteEvent",
1222
1287
  "PointerEvent",
libei/oeffis.py CHANGED
@@ -1,10 +1,12 @@
1
- """Pythonic wrapper around liboeffis -- negotiates an EIS connection through
2
- the ``org.freedesktop.portal.RemoteDesktop`` XDG desktop portal.
1
+ """Pythonic wrapper around liboeffis.
2
+
3
+ Negotiates an EIS connection through the
4
+ ``org.freedesktop.portal.RemoteDesktop`` XDG desktop portal.
3
5
 
4
6
  This is the path a sandboxed or otherwise non-privileged client uses to get
5
7
  an EI socket: it asks the portal, the user is shown a consent dialog, and on
6
8
  approval this hands back a file descriptor to pass to
7
- :meth:`libei.ei.Sender.create_for_fd`.
9
+ :meth:`libei.ei.Sender.create_for_fd`::
8
10
 
9
11
  oeffis = Oeffis.create(devices=DeviceType.POINTER)
10
12
  while True:
@@ -39,6 +41,7 @@ import logging
39
41
  import os
40
42
 
41
43
  from . import _capi
44
+ from ._capi.loader import LibraryNotFoundError
42
45
 
43
46
  logger = logging.getLogger("libei.oeffis")
44
47
 
@@ -52,6 +55,7 @@ class DisconnectedError(Exception):
52
55
  """The portal session ended unexpectedly (error, or denied by the user)."""
53
56
 
54
57
  def __init__(self, message: str | None) -> None:
58
+ """Record why the session ended."""
55
59
  super().__init__(message)
56
60
  self.message = message
57
61
 
@@ -60,6 +64,7 @@ class SessionClosedError(DisconnectedError):
60
64
  """The portal explicitly closed the session (not necessarily an error)."""
61
65
 
62
66
  def __init__(self) -> None:
67
+ """Build the fixed "Session closed" message."""
63
68
  super().__init__(message="Session closed")
64
69
 
65
70
 
@@ -100,6 +105,7 @@ class Oeffis:
100
105
  """
101
106
 
102
107
  def __init__(self) -> None:
108
+ """Create the underlying liboeffis context (no portal call yet)."""
103
109
  pointer = _capi.liboeffis.new(None)
104
110
  if not pointer:
105
111
  raise DisconnectedError("oeffis_new() returned NULL")
@@ -117,6 +123,11 @@ class Oeffis:
117
123
  self._state = _EventType.NONE
118
124
 
119
125
  def __del__(self) -> None:
126
+ """Release the session, closing the EIS fd if nobody claimed it.
127
+
128
+ Safe on a half-built object: __init__ can raise before every
129
+ attribute exists, and this still runs.
130
+ """
120
131
  # getattr() with defaults rather than plain attribute access:
121
132
  # __init__ raises DisconnectedError when oeffis_new() returns NULL,
122
133
  # and Python still calls __del__ on the half-built object, where
@@ -232,6 +243,11 @@ class Oeffis:
232
243
  __all__ = [
233
244
  "DeviceType",
234
245
  "DisconnectedError",
246
+ # Imported rather than defined here: every call bound through
247
+ # _capi.liboeffis can raise it -- Oeffis.create() on a machine with no
248
+ # liboeffis is the ordinary one -- so a caller importing from this
249
+ # module catches it from here too. See docs/troubleshooting.md.
250
+ "LibraryNotFoundError",
235
251
  "Oeffis",
236
252
  "SessionClosedError",
237
253
  "is_available",
libei/portal.py CHANGED
@@ -1,5 +1,7 @@
1
- """Negotiate an EIS connection by driving a portal directly over D-Bus,
2
- rather than through :mod:`libei.oeffis`.
1
+ """Negotiate an EIS connection by driving a portal directly over D-Bus.
2
+
3
+ Not through :mod:`libei.oeffis` -- see that module instead for the simpler,
4
+ liboeffis-backed path.
3
5
 
4
6
  Two portals, two directions. :class:`RemoteDesktopSession` negotiates
5
7
  ``org.freedesktop.portal.RemoteDesktop`` to *inject* input; below it,
@@ -19,7 +21,7 @@ should be handled by an application talking to DBus directly"
19
21
  (https://libinput.pages.freedesktop.org/libei/api/group__liboeffis.html).
20
22
  This module is that: the ``CreateSession`` -> ``SelectDevices`` -> ``Start``
21
23
  -> ``ConnectToEIS`` sequence driven directly, with ``persist_mode`` and
22
- ``restore_token`` exposed as real parameters.
24
+ ``restore_token`` exposed as real parameters::
23
25
 
24
26
  with RemoteDesktopSession.negotiate(
25
27
  devices=DeviceType.POINTER | DeviceType.KEYBOARD,
@@ -69,6 +71,7 @@ Three things worth knowing before building on this:
69
71
 
70
72
  from __future__ import annotations
71
73
 
74
+ import contextlib
72
75
  import enum
73
76
  import logging
74
77
  import os
@@ -104,7 +107,7 @@ _MIN_REMOTE_DESKTOP_VERSION = 2 # ConnectToEIS needs v2+
104
107
  _MIN_INPUT_CAPTURE_VERSION = 2 # CreateSession2 is a v2-only method
105
108
 
106
109
  _DEFAULT_TIMEOUT = 60.0
107
- """Seconds to wait for one portal round trip. Generous, because a human has
110
+ """Seconds to wait for one portal round trip. Generous, because a user has
108
111
  to see and answer the consent dialog `Start` raises -- but bounded, because
109
112
  the alternative is a caller wedged forever if the portal dies after
110
113
  accepting the call and before sending its `Response`."""
@@ -156,11 +159,12 @@ class PortalTimeoutError(PortalError):
156
159
 
157
160
  Distinct from a decline: the portal accepted the call and then never
158
161
  sent its ``Response`` signal. Most often the consent dialog is simply
159
- still waiting for a human, so raise the timeout rather than treating
162
+ still waiting for a user, so raise the timeout rather than treating
160
163
  this as a failure if that is expected.
161
164
  """
162
165
 
163
166
  def __init__(self, step: str, timeout: float) -> None:
167
+ """Name which step timed out and after how long."""
164
168
  super().__init__(f"{step} did not answer within {timeout:g}s")
165
169
  self.step = step
166
170
  self.timeout = timeout
@@ -175,6 +179,7 @@ class PortalDeniedError(PortalError):
175
179
  """
176
180
 
177
181
  def __init__(self, step: str, message: str | None = None) -> None:
182
+ """Name which step was refused and, where the portal gave one, why."""
178
183
  super().__init__(message or f"{step} was not approved")
179
184
  self.step = step
180
185
  self.message = message
@@ -422,18 +427,21 @@ def _request(
422
427
  params: Any,
423
428
  *_a: Any,
424
429
  ) -> None:
430
+ """Record the first reply and quit the loop; later replies are ignored."""
425
431
  if result: # both subscriptions may fire; the first reply wins
426
432
  return
427
433
  result["code"], result["results"] = params.unpack()
428
434
  loop.quit()
429
435
 
430
436
  def on_timeout() -> bool:
437
+ """Stop the loop and record that it timed out rather than finished."""
431
438
  nonlocal timed_out
432
439
  timed_out = True
433
440
  loop.quit()
434
441
  return False # one-shot; GLib removes the source when this is False
435
442
 
436
443
  def subscribe(path: str) -> None:
444
+ """Subscribe to Response on one path, keeping the handle alive."""
437
445
  subscriptions.append(
438
446
  connection.signal_subscribe(
439
447
  busname,
@@ -481,10 +489,16 @@ def _request(
481
489
  try:
482
490
  loop.run()
483
491
  finally:
484
- # Removing an already-fired one-shot source is harmless
485
- # (GLib warns at most); leaking a live one holds a reference
486
- # to this closure and fires it into a dead loop later.
487
- GLib.source_remove(timeout_source)
492
+ # on_timeout() returns False, which is GLib's own signal to
493
+ # deregister a fired one-shot source -- removing it again
494
+ # here trips a real "Source ID N was not found when
495
+ # attempting to remove it" warning, the same bug already
496
+ # fixed for _wait_for_signal below. Only remove it when the
497
+ # *Response* woke the loop instead; leaving a live source in
498
+ # that case holds a reference to this closure and fires it
499
+ # into a dead loop on some later request.
500
+ if not timed_out:
501
+ GLib.source_remove(timeout_source)
488
502
  finally:
489
503
  for subscription in subscriptions:
490
504
  connection.signal_unsubscribe(subscription)
@@ -584,6 +598,11 @@ class RemoteDesktopSession:
584
598
  session_handle: str | None = None,
585
599
  busname: str = _BUS_NAME,
586
600
  ) -> None:
601
+ """Hold a negotiated session's handle, connection and EIS fd.
602
+
603
+ Not for direct use -- built by :meth:`negotiate` once ``Start`` and
604
+ ``ConnectToEIS`` have both already succeeded.
605
+ """
587
606
  self._connection = connection
588
607
  self._eis_fd: int | None = eis_fd
589
608
  self._session_handle = session_handle
@@ -669,12 +688,20 @@ class RemoteDesktopSession:
669
688
  self._connection = None
670
689
 
671
690
  def __enter__(self) -> RemoteDesktopSession:
691
+ """Return self: entering performs no action of its own."""
672
692
  return self
673
693
 
674
694
  def __exit__(self, *_exc: Any) -> None:
695
+ """Close the session on the way out, whatever happened."""
675
696
  self.close()
676
697
 
677
698
  def __del__(self) -> None:
699
+ """Close the fd only: __del__ can run during interpreter shutdown.
700
+
701
+ A synchronous D-Bus round trip there may hang or fail with nothing
702
+ left able to report it, so the D-Bus half of close() is deliberately
703
+ not attempted.
704
+ """
678
705
  # Deliberately only the fd, not the D-Bus half of close(): __del__
679
706
  # can run during interpreter shutdown, where a synchronous D-Bus
680
707
  # round trip may hang or fail in ways nothing can report. Closing an
@@ -687,10 +714,9 @@ class RemoteDesktopSession:
687
714
  return
688
715
  eis_fd = getattr(self, "_eis_fd", None)
689
716
  if eis_fd is not None:
690
- try:
717
+ # an exception here is only printed to stderr anyway
718
+ with contextlib.suppress(OSError):
691
719
  os.close(eis_fd)
692
- except OSError:
693
- pass # an exception here is only printed to stderr anyway
694
720
 
695
721
  @classmethod
696
722
  def negotiate(
@@ -819,7 +845,7 @@ class RemoteDesktopSession:
819
845
  timeout=timeout,
820
846
  )
821
847
  except BaseException:
822
- # BaseException, not Exception: `Start` blocks on a human
848
+ # BaseException, not Exception: `Start` blocks on a user
823
849
  # answering a consent dialog, so Ctrl-C during that wait is a
824
850
  # routine way out of this function -- and it strands an
825
851
  # approved session exactly as a decline does.
@@ -980,10 +1006,10 @@ def _wait_for_signal(
980
1006
  busname: str,
981
1007
  interface: str,
982
1008
  signal: str,
983
- path: str,
1009
+ session_handle: str,
984
1010
  timeout: float | None,
985
1011
  ) -> tuple[Any, ...]:
986
- """Block for one emission of ``signal`` on ``path``, unpacked.
1012
+ """Block for one emission of ``signal`` for ``session_handle``, unpacked.
987
1013
 
988
1014
  Unlike `_request`, nothing here *triggers* the signal: ``Activated`` and
989
1015
  ``Deactivated`` fire whenever the compositor decides a pointer barrier
@@ -991,7 +1017,23 @@ def _wait_for_signal(
991
1017
  before this is even called (a long-enabled session, subscribed to
992
1018
  late) or not for a long time. ``timeout=None`` waits indefinitely --
993
1019
  the read a caller wants when there is nothing else useful to do but
994
- wait for a human to move the pointer.
1020
+ wait for a user to move the pointer.
1021
+
1022
+ **These signals are emitted on the portal object, not on the session
1023
+ object.** Subscribing with the session handle as the D-Bus object path
1024
+ -- the obvious reading of "a signal for this session", and what this
1025
+ did until 2026-09-09 -- matches nothing, delivers nothing, and is
1026
+ indistinguishable from a compositor that never fires the signal at all:
1027
+ it cost this project a live investigation across two GNOME versions, a
1028
+ standalone C reproducer and an upstream Mutter bug report before an
1029
+ xdg-desktop-portal developer pointed out the mistake. The session is
1030
+ identified by the signal's own first argument instead (``o
1031
+ session_handle``, per the portal spec), so the subscription is on
1032
+ `_OBJECT_PATH` and the filtering happens here, on the payload. It is
1033
+ deliberately not done with ``signal_subscribe``'s ``arg0`` filter: the
1034
+ D-Bus specification restricts plain ``arg0=`` match rules to arguments
1035
+ of type STRING, and this one is an OBJECT_PATH, which is the same shape
1036
+ of silent non-delivery all over again.
995
1037
  """
996
1038
  loop = GLib.MainLoop()
997
1039
  result: dict[str, Any] = {}
@@ -1006,12 +1048,29 @@ def _wait_for_signal(
1006
1048
  params: Any,
1007
1049
  *_a: Any,
1008
1050
  ) -> None:
1051
+ """Record the first matching signal; anything else is logged and dropped."""
1009
1052
  if result: # a subscription that outlives its own wait can fire twice
1010
1053
  return
1011
- result["args"] = params.unpack()
1054
+ args = params.unpack()
1055
+ if args[0] != session_handle:
1056
+ # Logged, not silently dropped: this filter can hide a signal
1057
+ # that *did* arrive just as effectively as the wrong-path
1058
+ # subscription above did, and the two are indistinguishable
1059
+ # from the outside -- both look exactly like a compositor that
1060
+ # never fired. One debug line is what tells them apart.
1061
+ logger.debug(
1062
+ "ignoring %s for session %s while waiting for %s",
1063
+ signal,
1064
+ args[0],
1065
+ session_handle,
1066
+ )
1067
+ return
1068
+ logger.debug("received %s for session %s", signal, session_handle)
1069
+ result["args"] = args
1012
1070
  loop.quit()
1013
1071
 
1014
1072
  def on_timeout() -> bool:
1073
+ """Stop the loop and record that it timed out rather than finished."""
1015
1074
  nonlocal timed_out
1016
1075
  timed_out = True
1017
1076
  loop.quit()
@@ -1021,12 +1080,20 @@ def _wait_for_signal(
1021
1080
  busname,
1022
1081
  interface,
1023
1082
  signal,
1024
- path,
1083
+ _OBJECT_PATH,
1025
1084
  None,
1026
1085
  Gio.DBusSignalFlags.NONE,
1027
1086
  on_signal,
1028
1087
  None,
1029
1088
  )
1089
+ logger.debug(
1090
+ "waiting up to %s for %s.%s on %s for session %s",
1091
+ "forever" if timeout is None else f"{timeout:g}s",
1092
+ interface,
1093
+ signal,
1094
+ _OBJECT_PATH,
1095
+ session_handle,
1096
+ )
1030
1097
  try:
1031
1098
  if not result:
1032
1099
  timeout_source = None
@@ -1035,7 +1102,16 @@ def _wait_for_signal(
1035
1102
  try:
1036
1103
  loop.run()
1037
1104
  finally:
1038
- if timeout_source is not None:
1105
+ # A fired timeout source has already deregistered itself --
1106
+ # on_timeout() returns False, which is GLib's signal to
1107
+ # remove it -- so only remove it here when the *signal*
1108
+ # woke the loop instead. Removing it unconditionally trips
1109
+ # a real GLib warning ("Source ID N was not found when
1110
+ # attempting to remove it"), confirmed live: it fired on
1111
+ # every real timeout this session hit, never against
1112
+ # test_portal.py's fake GLib.source_remove, which just
1113
+ # clears pending_timeout with no complaint either way.
1114
+ if timeout_source is not None and not timed_out:
1039
1115
  GLib.source_remove(timeout_source)
1040
1116
  finally:
1041
1117
  connection.signal_unsubscribe(subscription)
@@ -1049,8 +1125,9 @@ def _wait_for_signal(
1049
1125
 
1050
1126
 
1051
1127
  class Activation(NamedTuple):
1052
- """One ``Activated`` signal's payload -- see
1053
- :meth:`InputCaptureSession.wait_for_activation`.
1128
+ """One ``Activated`` signal's payload.
1129
+
1130
+ See :meth:`InputCaptureSession.wait_for_activation`.
1054
1131
  """
1055
1132
 
1056
1133
  activation_id: int
@@ -1077,8 +1154,8 @@ class InputCaptureSession:
1077
1154
  direction: instead of injecting synthetic input, this receives real
1078
1155
  input from the user's own devices once the compositor decides to divert
1079
1156
  it here. That decision is the whole point of the protocol and is never
1080
- this session's to make -- see :meth:`enable` and :meth:`
1081
- wait_for_activation`.
1157
+ this session's to make -- see :meth:`enable` and
1158
+ :meth:`wait_for_activation`.
1082
1159
 
1083
1160
  **Capturing is exclusive.** Once the compositor activates a capture,
1084
1161
  the events it captures stop reaching the desktop entirely and are sent
@@ -1101,10 +1178,10 @@ class InputCaptureSession:
1101
1178
 
1102
1179
  **Never live-tested.** Every other class in this module that talks to a
1103
1180
  real portal carries a hand-verification note in its own docstring; this
1104
- one does not, because verifying it means a human clicking through the
1105
- consent dialog *and* accepting that their pointer will be diverted away
1106
- from their own desktop for the length of the test -- not something to
1107
- trigger without asking first, unlike everything else here. Designed
1181
+ one does not, because verifying it means a developer clicking through
1182
+ the consent dialog *and* accepting that their pointer will be diverted
1183
+ away from their own desktop for the length of the test -- not something
1184
+ to trigger without asking first, unlike everything else here. Designed
1108
1185
  against ``/usr/share/dbus-1/interfaces/org.freedesktop.portal.
1109
1186
  InputCapture.xml`` (the shipped portal spec, not the header alone) and
1110
1187
  unit-tested against a fake connection reproducing that spec's documented
@@ -1120,6 +1197,11 @@ class InputCaptureSession:
1120
1197
  restore_token: str | None,
1121
1198
  busname: str = _BUS_NAME,
1122
1199
  ) -> None:
1200
+ """Hold a negotiated capture session's handle, connection and EIS fd.
1201
+
1202
+ Not for direct use -- built by :meth:`negotiate` once ``ConnectToEIS``
1203
+ has already succeeded.
1204
+ """
1123
1205
  self._connection = connection
1124
1206
  self._session_handle: str | None = session_handle
1125
1207
  self._eis_fd: int | None = eis_fd
@@ -1320,13 +1402,24 @@ class InputCaptureSession:
1320
1402
  )
1321
1403
  if code != 0:
1322
1404
  raise PortalDeniedError("SetPointerBarriers")
1323
- return list(results.get("failed_barriers", []))
1405
+ failed = list(results.get("failed_barriers", []))
1406
+ # A partially-refused set is the quiet failure here: the call
1407
+ # succeeds, some barriers stand, and an edge the caller believes is
1408
+ # armed simply never triggers. Callers see only the returned list,
1409
+ # which they may or may not act on -- this says it either way.
1410
+ logger.debug(
1411
+ "SetPointerBarriers: %d requested %s, refused: %s",
1412
+ len(barriers),
1413
+ [b[0] for b in barriers],
1414
+ failed or "none",
1415
+ )
1416
+ return failed
1324
1417
 
1325
1418
  def wait_for_activation(self, timeout: float | None = None) -> Activation:
1326
1419
  """Block until the compositor activates capture, or ``timeout``.
1327
1420
 
1328
1421
  Only returns once a real barrier crossing has been reported --
1329
- which, on hardware, means a human moved a physical pointer across
1422
+ which, on hardware, means a user moved a physical pointer across
1330
1423
  one. There is no way to trigger this synthetically (see the class
1331
1424
  docstring's third paragraph), so this call can legitimately hang
1332
1425
  until someone does that, and `timeout=None` -- the default -- waits
@@ -1407,21 +1500,22 @@ class InputCaptureSession:
1407
1500
  self._connection = None
1408
1501
 
1409
1502
  def __enter__(self) -> InputCaptureSession:
1503
+ """Return self: entering performs no action of its own."""
1410
1504
  return self
1411
1505
 
1412
1506
  def __exit__(self, *_exc: Any) -> None:
1507
+ """Close the session on the way out, whatever happened."""
1413
1508
  self.close()
1414
1509
 
1415
1510
  def __del__(self) -> None:
1511
+ """Close the fd only, for the reason RemoteDesktopSession.__del__ gives."""
1416
1512
  # See RemoteDesktopSession.__del__ for why this closes only the fd.
1417
1513
  if getattr(self, "_eis_fd_claimed", True):
1418
1514
  return
1419
1515
  eis_fd = getattr(self, "_eis_fd", None)
1420
1516
  if eis_fd is not None:
1421
- try:
1517
+ with contextlib.suppress(OSError):
1422
1518
  os.close(eis_fd)
1423
- except OSError:
1424
- pass
1425
1519
 
1426
1520
  @classmethod
1427
1521
  def negotiate(
@@ -1590,7 +1684,7 @@ class InputCaptureSession:
1590
1684
  timeout,
1591
1685
  )
1592
1686
  except BaseException:
1593
- # BaseException, not Exception: Start blocks on a human
1687
+ # BaseException, not Exception: Start blocks on a user
1594
1688
  # answering a consent dialog, so Ctrl-C during that wait is a
1595
1689
  # routine way out of this function -- and it strands an
1596
1690
  # approved session exactly as a decline does.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-libei
3
- Version: 0.5.1
3
+ Version: 0.6.0
4
4
  Summary: Inject and receive input on Wayland from Python: ctypes bindings for libei, libeis and liboeffis
5
5
  Author: Dennis K. Paulsen
6
6
  License-Expression: MIT
@@ -37,6 +37,10 @@ Dynamic: license-file
37
37
 
38
38
  # python-libei
39
39
 
40
+ [![CI](https://github.com/ctrondlp/python-libei/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/ctrondlp/python-libei/actions/workflows/ci.yml)
41
+ [![PyPI](https://img.shields.io/pypi/v/python-libei)](https://pypi.org/project/python-libei/)
42
+ [![License](https://img.shields.io/pypi/l/python-libei)](https://github.com/ctrondlp/python-libei/blob/main/LICENSE)
43
+
40
44
  Python bindings for [libei, libeis and liboeffis](https://libinput.pages.freedesktop.org/libei/) —
41
45
  the Wayland input-emulation libraries. Use this to **move the pointer, click,
42
46
  type, or scroll on a Wayland desktop** from Python, the way `xdotool` did on
@@ -103,11 +107,15 @@ to get permission, then `ei` to inject.
103
107
  | **Get permission**, and not be asked again | `libei.portal` | Same handshake over D-Bus directly, with `persist_mode` / `restore_token`. Needs PyGObject |
104
108
  | **Be the server**, for tests or a compositor | `libei.eis` | Drives your client code with no real compositor and no consent dialog |
105
109
 
106
- Each module has `is_available()`, an `Error` exception, and an `EventType` /
107
- `DeviceCapability` enum. `ei` and `eis` also share the shapes around them:
108
- `Device`, `Seat`, `Region`, `Keymap`, `Touch`, `Ping`, `Event`, and the frozen
109
- dataclasses its accessors return. The package ships `py.typed`, so callers
110
- type-check against real annotations rather than `Any`.
110
+ Each module has `is_available()`. `ei` and `eis` share the shapes around them:
111
+ an `Error` exception, an `EventType` and `DeviceCapability` enum, and `Device`,
112
+ `Seat`, `Region`, `Keymap`, `Touch`, `Ping`, `Event` with the frozen dataclasses
113
+ its accessors return. `oeffis` and `portal` are smaller — `DeviceType`, no
114
+ `EventType` or `DeviceCapability`, `Activation` as the one frozen result a
115
+ portal wait hands back, and their own exception classes rather than an `Error`.
116
+ [docs/troubleshooting.md](docs/troubleshooting.md) names every class they raise.
117
+ The package ships `py.typed`, so callers type-check against real
118
+ annotations rather than `Any`.
111
119
 
112
120
  ## What's implemented
113
121
 
@@ -149,14 +157,16 @@ Beyond sending input, the wrapper also covers ping/pong round trips
149
157
  (`Device.keymap`), region mapping ids and coordinate conversion,
150
158
  `Context.disconnect()`, `Context.peek_event_type()`, and
151
159
  `Seat.request_device()`. On the server side, `libei.eis` mirrors all of it and
152
- adds `Eis.set_flag()` and `Client.pid`. Underneath, the ctypes layer binds 250
160
+ adds `Eis.set_flag()` (with the `Flag` values it takes), `Client.pid`, and
161
+ `Device.configure()` with the `ConfigureRegion` descriptions it accepts.
162
+ Underneath, the ctypes layer binds 250
153
163
  of the 302 functions the three libraries export as of 1.6.0; what is left out,
154
164
  and why, is in
155
165
  [docs/developers/architecture.md](docs/developers/architecture.md#what-is-bound-and-what-is-deliberately-not).
156
166
 
157
167
  ## Status
158
168
 
159
- Beta (`0.5.1`), published on [PyPI](https://pypi.org/project/python-libei/)
169
+ Beta (`0.6.0`), published on [PyPI](https://pypi.org/project/python-libei/)
160
170
  since `0.1.0`, and **the API is not frozen** — expect renames before 1.0.
161
171
 
162
172
  The injection path is exercised end to end against the real libraries by the
@@ -187,11 +197,11 @@ Exactly what was run, when, and against which versions:
187
197
  portal paths are the part likeliest to come up short off Linux, since
188
198
  they need an xdg-desktop-portal RemoteDesktop backend to talk to.
189
199
  - CPython 3.10 or newer (tested on 3.13)
190
- - The native libraries: on Fedora, `sudo dnf install libei libeis liboeffis`;
191
- on FreeBSD, `pkg install libei` (the `x11/libei` port), which supplies all
192
- three sonames including `liboeffis`
193
- - `libei.portal` only: PyGObject (`pip install 'python-libei[portal]'`), plus
194
- whatever GObject-introspection libraries your distro needs for `Gio` --
200
+ - The native `libei`, `libeis` and `liboeffis` libraries, which `pip` cannot
201
+ supply. Which package provides them on your distribution — and what to do
202
+ when the name does not match — is in [docs/install.md](docs/install.md).
203
+ - `libei.portal` only: PyGObject, via the `portal` extra, plus whatever
204
+ GObject-introspection libraries your distribution needs for `Gio`, since
195
205
  PyPI's PyGObject wheel supplies the Python side only. Not needed for
196
206
  `libei.ei`, `libei.eis` or `libei.oeffis`.
197
207
  - libei 1.0.0 or newer for the core: connecting, binding a seat, and
@@ -224,22 +234,11 @@ Exactly what was run, when, and against which versions:
224
234
 
225
235
  ## Install
226
236
 
227
- From [PyPI](https://pypi.org/project/python-libei/):
228
-
229
237
  ```sh
230
238
  pip install python-libei
231
239
  ```
232
240
 
233
- The distribution is named `python-libei`, the import is `libei` -- so
234
- `pip show python-libei`, but `from libei import ei`.
235
-
236
- Pure Python, no build step: the wheel is `py3-none-any` and ctypes talks to
237
- the native libraries directly, so there is no compiler, no headers and no
238
- `libei-devel` involved at install time. What `pip` does *not* bring is the
239
- native libraries themselves -- see [Requirements](#requirements) above; on
240
- Fedora, `sudo dnf install libei libeis liboeffis`.
241
-
242
- To track `main` instead, or to hack on it, install from a checkout:
241
+ From a checkout instead, to track `main` or to work on the package:
243
242
 
244
243
  ```sh
245
244
  git clone https://github.com/ctrondlp/python-libei.git
@@ -247,15 +246,11 @@ cd python-libei
247
246
  pip install . # or `pip install -e '.[dev]'` to develop
248
247
  ```
249
248
 
250
- Importing is always safe, even where the native libraries are missing — they
251
- are loaded on first use, not at import. Check before you rely on them:
252
-
253
- ```python
254
- from libei import ei
255
-
256
- if not ei.is_available():
257
- ... # fall back to another input backend
258
- ```
249
+ That is the half `pip` can do. The native `libei`/`libeis`/`liboeffis`
250
+ libraries it cannot supply, the `portal` extra, and how to check what actually
251
+ loaded are all in [docs/install.md](docs/install.md) — see
252
+ [Requirements](#requirements) above for the version floors. Importing is safe
253
+ without any of it: those libraries are loaded on first *use*, not at import.
259
254
 
260
255
  ## Concepts
261
256
 
@@ -409,8 +404,10 @@ a list of error messages. Start there when nothing happens.
409
404
  happens" checklist
410
405
  - [docs/vs-snegg.md](docs/vs-snegg.md) — how this differs from the reference
411
406
  bindings, and two signature issues found by cross-checking the C source
412
- - [docs/developers/](docs/developers/) — the four-layer architecture, and what
413
- has actually been verified against which libei versions
407
+ - [docs/developers/architecture.md](docs/developers/architecture.md) and
408
+ [docs/developers/verification.md](docs/developers/verification.md) — the
409
+ four-layer architecture, and what has actually been verified against which
410
+ libei versions
414
411
  - [CONTRIBUTING.md](CONTRIBUTING.md) — setup, checks, testing against an old
415
412
  libei, releasing
416
413
 
@@ -0,0 +1,17 @@
1
+ libei/__init__.py,sha256=ypdFYwFMulRHAB3iYKc2NfCvMRvAq9TUFuwQN9b3PWw,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=eTYzNkA_65VaUlbSkATiXmOA8c6_uUDle2c2lUgZZHY,69698
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=gpp42bhqPonhVD2zmFWCl4rdk_lm4XvD_7W2Rw5RsuI,11308
10
+ libei/_capi/libeis.py,sha256=jQR0Z0YN9qa71mAcSJ__-UaoF8PsVsBs8OXwjfqstFk,13080
11
+ libei/_capi/liboeffis.py,sha256=V8jdmJm3qOQUzzNiCAXt4Jr2JY11Y4-w6NIfIxbxGI4,1218
12
+ libei/_capi/loader.py,sha256=QD1xQJo05q4eSWhOCRpAWOlzHD5fcnHtI6yQyy4rKs0,5052
13
+ python_libei-0.6.0.dist-info/licenses/LICENSE,sha256=l6xbMU6Y-JZDzmBciBj-J5t6h6jNg_Bcyp4bHxDepqU,1074
14
+ python_libei-0.6.0.dist-info/METADATA,sha256=NC7ygbrH78TjzREhLvF7-FSz8kNgf8tPE21NZQiykp4,20963
15
+ python_libei-0.6.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
16
+ python_libei-0.6.0.dist-info/top_level.txt,sha256=_DQXzGjDsUBENI_cNkiOxPB4xi8coCbQS1lq18FMudQ,6
17
+ python_libei-0.6.0.dist-info/RECORD,,
@@ -1,17 +0,0 @@
1
- libei/__init__.py,sha256=ZknUZWRNN4JfCH08NGpbBhqr_e2YZjHnk8B1aXe2J9Y,1858
2
- libei/_cobject.py,sha256=msrbviABSWjc5fKsXHSCg7nQ0Y4Etcq_Dkh0fblW-sY,12845
3
- libei/ei.py,sha256=Da3ezXnFPXtejlcJFH8bcE5f4pxKZXXqb6ltCh2zAdE,46704
4
- libei/eis.py,sha256=aYWOCN1NlRchcWd6NjIUi1eYMFWJbp0UHMcQFEJ0ii4,43642
5
- libei/oeffis.py,sha256=UcnDwLErFxB0xuktNpIzAK01ylpl6ZrmaGnWiUOcFOo,9302
6
- libei/portal.py,sha256=LGRINrYQ-x46i0pzGOzpM6ZT3xek_AoExNIgR2BZRB8,64498
7
- libei/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
- libei/_capi/__init__.py,sha256=3VxixYYlr_ZcuiK0GwrCpbE3VvEv_O990mJzdIJ8i74,209
9
- libei/_capi/libei.py,sha256=gpp42bhqPonhVD2zmFWCl4rdk_lm4XvD_7W2Rw5RsuI,11308
10
- libei/_capi/libeis.py,sha256=jQR0Z0YN9qa71mAcSJ__-UaoF8PsVsBs8OXwjfqstFk,13080
11
- libei/_capi/liboeffis.py,sha256=V8jdmJm3qOQUzzNiCAXt4Jr2JY11Y4-w6NIfIxbxGI4,1218
12
- libei/_capi/loader.py,sha256=k7fb_Nz0fg5QkuLJ_duKXj8bESSktLQ2ZyS5LNQ5v2I,4689
13
- python_libei-0.5.1.dist-info/licenses/LICENSE,sha256=l6xbMU6Y-JZDzmBciBj-J5t6h6jNg_Bcyp4bHxDepqU,1074
14
- python_libei-0.5.1.dist-info/METADATA,sha256=Bg1meYzLmMAp9HqeMWbU4SzZZOurlSGzIR49ZmoJNgo,20475
15
- python_libei-0.5.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
16
- python_libei-0.5.1.dist-info/top_level.txt,sha256=_DQXzGjDsUBENI_cNkiOxPB4xi8coCbQS1lq18FMudQ,6
17
- python_libei-0.5.1.dist-info/RECORD,,