python-libei 0.5.2__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. {python_libei-0.5.2 → python_libei-0.6.0}/PKG-INFO +22 -10
  2. {python_libei-0.5.2 → python_libei-0.6.0}/README.md +21 -9
  3. {python_libei-0.5.2 → python_libei-0.6.0}/pyproject.toml +9 -1
  4. {python_libei-0.5.2 → python_libei-0.6.0}/src/libei/__init__.py +1 -1
  5. {python_libei-0.5.2 → python_libei-0.6.0}/src/libei/_capi/loader.py +7 -0
  6. {python_libei-0.5.2 → python_libei-0.6.0}/src/libei/_cobject.py +6 -0
  7. {python_libei-0.5.2 → python_libei-0.6.0}/src/libei/ei.py +19 -0
  8. {python_libei-0.5.2 → python_libei-0.6.0}/src/libei/eis.py +17 -0
  9. {python_libei-0.5.2 → python_libei-0.6.0}/src/libei/oeffis.py +12 -1
  10. {python_libei-0.5.2 → python_libei-0.6.0}/src/libei/portal.py +19 -3
  11. {python_libei-0.5.2 → python_libei-0.6.0}/src/python_libei.egg-info/PKG-INFO +22 -10
  12. {python_libei-0.5.2 → python_libei-0.6.0}/tests/test_documentation_shape.py +167 -14
  13. {python_libei-0.5.2 → python_libei-0.6.0}/tests/test_documented_examples.py +0 -27
  14. {python_libei-0.5.2 → python_libei-0.6.0}/tests/test_portal.py +14 -0
  15. {python_libei-0.5.2 → python_libei-0.6.0}/LICENSE +0 -0
  16. {python_libei-0.5.2 → python_libei-0.6.0}/setup.cfg +0 -0
  17. {python_libei-0.5.2 → python_libei-0.6.0}/src/libei/_capi/__init__.py +0 -0
  18. {python_libei-0.5.2 → python_libei-0.6.0}/src/libei/_capi/libei.py +0 -0
  19. {python_libei-0.5.2 → python_libei-0.6.0}/src/libei/_capi/libeis.py +0 -0
  20. {python_libei-0.5.2 → python_libei-0.6.0}/src/libei/_capi/liboeffis.py +0 -0
  21. {python_libei-0.5.2 → python_libei-0.6.0}/src/libei/py.typed +0 -0
  22. {python_libei-0.5.2 → python_libei-0.6.0}/src/python_libei.egg-info/SOURCES.txt +0 -0
  23. {python_libei-0.5.2 → python_libei-0.6.0}/src/python_libei.egg-info/dependency_links.txt +0 -0
  24. {python_libei-0.5.2 → python_libei-0.6.0}/src/python_libei.egg-info/requires.txt +0 -0
  25. {python_libei-0.5.2 → python_libei-0.6.0}/src/python_libei.egg-info/top_level.txt +0 -0
  26. {python_libei-0.5.2 → python_libei-0.6.0}/tests/test_cobject.py +0 -0
  27. {python_libei-0.5.2 → python_libei-0.6.0}/tests/test_ei_objects.py +0 -0
  28. {python_libei-0.5.2 → python_libei-0.6.0}/tests/test_eis_objects.py +0 -0
  29. {python_libei-0.5.2 → python_libei-0.6.0}/tests/test_inputcapture.py +0 -0
  30. {python_libei-0.5.2 → python_libei-0.6.0}/tests/test_integration_extras.py +0 -0
  31. {python_libei-0.5.2 → python_libei-0.6.0}/tests/test_integration_socketpair.py +0 -0
  32. {python_libei-0.5.2 → python_libei-0.6.0}/tests/test_loader.py +0 -0
  33. {python_libei-0.5.2 → python_libei-0.6.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.5.2
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.2`), 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
@@ -394,8 +404,10 @@ a list of error messages. Start there when nothing happens.
394
404
  happens" checklist
395
405
  - [docs/vs-snegg.md](docs/vs-snegg.md) — how this differs from the reference
396
406
  bindings, and two signature issues found by cross-checking the C source
397
- - [docs/developers/](docs/developers/) — the four-layer architecture, and what
398
- 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
399
411
  - [CONTRIBUTING.md](CONTRIBUTING.md) — setup, checks, testing against an old
400
412
  libei, releasing
401
413
 
@@ -1,5 +1,9 @@
1
1
  # python-libei
2
2
 
3
+ [![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)
4
+ [![PyPI](https://img.shields.io/pypi/v/python-libei)](https://pypi.org/project/python-libei/)
5
+ [![License](https://img.shields.io/pypi/l/python-libei)](https://github.com/ctrondlp/python-libei/blob/main/LICENSE)
6
+
3
7
  Python bindings for [libei, libeis and liboeffis](https://libinput.pages.freedesktop.org/libei/) —
4
8
  the Wayland input-emulation libraries. Use this to **move the pointer, click,
5
9
  type, or scroll on a Wayland desktop** from Python, the way `xdotool` did on
@@ -66,11 +70,15 @@ to get permission, then `ei` to inject.
66
70
  | **Get permission**, and not be asked again | `libei.portal` | Same handshake over D-Bus directly, with `persist_mode` / `restore_token`. Needs PyGObject |
67
71
  | **Be the server**, for tests or a compositor | `libei.eis` | Drives your client code with no real compositor and no consent dialog |
68
72
 
69
- Each module has `is_available()`, an `Error` exception, and an `EventType` /
70
- `DeviceCapability` enum. `ei` and `eis` also share the shapes around them:
71
- `Device`, `Seat`, `Region`, `Keymap`, `Touch`, `Ping`, `Event`, and the frozen
72
- dataclasses its accessors return. The package ships `py.typed`, so callers
73
- type-check against real annotations rather than `Any`.
73
+ Each module has `is_available()`. `ei` and `eis` share the shapes around them:
74
+ an `Error` exception, an `EventType` and `DeviceCapability` enum, and `Device`,
75
+ `Seat`, `Region`, `Keymap`, `Touch`, `Ping`, `Event` with the frozen dataclasses
76
+ its accessors return. `oeffis` and `portal` are smaller — `DeviceType`, no
77
+ `EventType` or `DeviceCapability`, `Activation` as the one frozen result a
78
+ portal wait hands back, and their own exception classes rather than an `Error`.
79
+ [docs/troubleshooting.md](docs/troubleshooting.md) names every class they raise.
80
+ The package ships `py.typed`, so callers type-check against real
81
+ annotations rather than `Any`.
74
82
 
75
83
  ## What's implemented
76
84
 
@@ -112,14 +120,16 @@ Beyond sending input, the wrapper also covers ping/pong round trips
112
120
  (`Device.keymap`), region mapping ids and coordinate conversion,
113
121
  `Context.disconnect()`, `Context.peek_event_type()`, and
114
122
  `Seat.request_device()`. On the server side, `libei.eis` mirrors all of it and
115
- adds `Eis.set_flag()` and `Client.pid`. Underneath, the ctypes layer binds 250
123
+ adds `Eis.set_flag()` (with the `Flag` values it takes), `Client.pid`, and
124
+ `Device.configure()` with the `ConfigureRegion` descriptions it accepts.
125
+ Underneath, the ctypes layer binds 250
116
126
  of the 302 functions the three libraries export as of 1.6.0; what is left out,
117
127
  and why, is in
118
128
  [docs/developers/architecture.md](docs/developers/architecture.md#what-is-bound-and-what-is-deliberately-not).
119
129
 
120
130
  ## Status
121
131
 
122
- Beta (`0.5.2`), published on [PyPI](https://pypi.org/project/python-libei/)
132
+ Beta (`0.6.0`), published on [PyPI](https://pypi.org/project/python-libei/)
123
133
  since `0.1.0`, and **the API is not frozen** — expect renames before 1.0.
124
134
 
125
135
  The injection path is exercised end to end against the real libraries by the
@@ -357,8 +367,10 @@ a list of error messages. Start there when nothing happens.
357
367
  happens" checklist
358
368
  - [docs/vs-snegg.md](docs/vs-snegg.md) — how this differs from the reference
359
369
  bindings, and two signature issues found by cross-checking the C source
360
- - [docs/developers/](docs/developers/) — the four-layer architecture, and what
361
- has actually been verified against which libei versions
370
+ - [docs/developers/architecture.md](docs/developers/architecture.md) and
371
+ [docs/developers/verification.md](docs/developers/verification.md) — the
372
+ four-layer architecture, and what has actually been verified against which
373
+ libei versions
362
374
  - [CONTRIBUTING.md](CONTRIBUTING.md) — setup, checks, testing against an old
363
375
  libei, releasing
364
376
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-libei"
7
- version = "0.5.2"
7
+ version = "0.6.0"
8
8
  description = "Inject and receive input on Wayland from Python: ctypes bindings for libei, libeis and liboeffis"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -73,6 +73,14 @@ libei = ["py.typed"]
73
73
 
74
74
  [tool.pytest.ini_options]
75
75
  testpaths = ["tests"]
76
+ # The suite imports `libei` from the checkout rather than requiring an
77
+ # install, so `scripts/pre-commit-test.sh` runs on an interpreter with only
78
+ # the dev tools in it -- which is what that script's own header asks for, and
79
+ # what pyguitest and pyguitest-recorder already do. CI installs the package,
80
+ # so the installed path is still covered there. Without this, conftest's
81
+ # `from libei import ei` fails at collection and the whole check reports an
82
+ # ImportError instead of running anything.
83
+ pythonpath = ["src"]
76
84
  markers = [
77
85
  "integration: requires the real libei/libeis/liboeffis shared libraries to be installed",
78
86
  ]
@@ -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.2"
34
+ __version__ = "0.6.0"
35
35
 
36
36
  __all__ = ["__version__"]
@@ -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")
@@ -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
@@ -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.
@@ -192,6 +193,8 @@ class KeymapType(enum.IntEnum):
192
193
 
193
194
 
194
195
  class _LogPriority(enum.IntEnum):
196
+ """The log levels, as the integers the C library passes to its handler."""
197
+
195
198
  DEBUG = 10
196
199
  INFO = 20
197
200
  WARNING = 30
@@ -329,6 +332,7 @@ class Region(CObject):
329
332
  _unref_func = staticmethod(_capi.libei.region_unref)
330
333
 
331
334
  def __repr__(self) -> str:
335
+ """The size and position, as WxH+X+Y."""
332
336
  w, h = self.dimension
333
337
  x, y = self.position
334
338
  return f"<Region {w}x{h}+{x}+{y}>"
@@ -510,6 +514,7 @@ class Device(CObject):
510
514
  _unref_func = staticmethod(_capi.libei.device_unref)
511
515
 
512
516
  def __repr__(self) -> str:
517
+ """Name, device type and capabilities -- what tells two devices apart."""
513
518
  caps = "|".join(c.name or str(c.value) for c in self.capabilities)
514
519
  return f"<Device {self.name!r} {self.device_type.name} {caps}>"
515
520
 
@@ -692,6 +697,7 @@ class Seat(CObject):
692
697
  _unref_func = staticmethod(_capi.libei.seat_unref)
693
698
 
694
699
  def __repr__(self) -> str:
700
+ """The seat's name and capabilities."""
695
701
  caps = "|".join(c.name or str(c.value) for c in self.capabilities)
696
702
  return f"<Seat {self.name!r} {caps}>"
697
703
 
@@ -774,6 +780,7 @@ class Ping(CObject):
774
780
  _unref_func = staticmethod(_capi.libei.ping_unref)
775
781
 
776
782
  def __repr__(self) -> str:
783
+ """The ping's id, which is all a ping carries."""
777
784
  return f"<Ping {self.id}>"
778
785
 
779
786
  @property
@@ -801,6 +808,7 @@ class Event(CObject):
801
808
  _unref_func = staticmethod(_capi.libei.event_unref)
802
809
 
803
810
  def __repr__(self) -> str:
811
+ """The event type by name, or the raw value for one we do not model."""
804
812
  event_type = self.event_type
805
813
  label = event_type.name if isinstance(event_type, EventType) else event_type
806
814
  return f"<Event {label}>"
@@ -1008,6 +1016,11 @@ class Event(CObject):
1008
1016
 
1009
1017
 
1010
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
+ """
1011
1024
  # Look up the raw int, not _LogPriority(priority): constructing the
1012
1025
  # enum from an unrecognized value raises ValueError immediately, which
1013
1026
  # would happen *before* .get()'s default ever gets a chance to apply
@@ -1227,6 +1240,7 @@ class Sender(Context):
1227
1240
 
1228
1241
  @classmethod
1229
1242
  def _new(cls) -> int:
1243
+ """A new sender from the C library, or an error if it returned NULL."""
1230
1244
  pointer = _capi.libei.new_sender(c_void_p(None))
1231
1245
  if not pointer:
1232
1246
  raise Error("ei_new_sender() returned NULL")
@@ -1250,6 +1264,7 @@ class Receiver(Context):
1250
1264
 
1251
1265
  @classmethod
1252
1266
  def _new(cls) -> int:
1267
+ """A new receiver from the C library, or an error if it returned NULL."""
1253
1268
  pointer = _capi.libei.new_receiver(c_void_p(None))
1254
1269
  if not pointer:
1255
1270
  raise Error("ei_new_receiver() returned NULL")
@@ -1280,6 +1295,10 @@ __all__ = [
1280
1295
  "KeyEvent",
1281
1296
  "Keymap",
1282
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",
1283
1302
  "Ping",
1284
1303
  "PointerAbsoluteEvent",
1285
1304
  "PointerEvent",
@@ -159,6 +159,8 @@ class Flag(enum.IntEnum):
159
159
 
160
160
 
161
161
  class _LogPriority(enum.IntEnum):
162
+ """The log levels, as the integers libeis passes to its log handler."""
163
+
162
164
  DEBUG = 10
163
165
  INFO = 20
164
166
  WARNING = 30
@@ -456,6 +458,7 @@ class Device(CObject):
456
458
  _unref_func = staticmethod(_capi.libeis.device_unref)
457
459
 
458
460
  def __repr__(self) -> str:
461
+ """Name, device type and capabilities -- what tells two devices apart."""
459
462
  caps = "|".join(c.name or str(c.value) for c in self.capabilities)
460
463
  return f"<Device {self.name!r} {self.device_type.name} {caps}>"
461
464
 
@@ -709,6 +712,7 @@ class Seat(CObject):
709
712
  _unref_func = staticmethod(_capi.libeis.seat_unref)
710
713
 
711
714
  def __repr__(self) -> str:
715
+ """The seat's name and capabilities."""
712
716
  caps = "|".join(c.name or str(c.value) for c in self.capabilities)
713
717
  return f"<Seat {self.name!r} {caps}>"
714
718
 
@@ -770,6 +774,7 @@ class Client(CObject):
770
774
  _unref_func = staticmethod(_capi.libeis.client_unref)
771
775
 
772
776
  def __repr__(self) -> str:
777
+ """The client's name and which side of the protocol it is on."""
773
778
  return f"<Client {self.name!r} sender={self.is_sender}>"
774
779
 
775
780
  @property
@@ -840,6 +845,7 @@ class Ping(CObject):
840
845
  _unref_func = staticmethod(_capi.libeis.ping_unref)
841
846
 
842
847
  def __repr__(self) -> str:
848
+ """The ping's id, which is all a ping carries."""
843
849
  return f"<Ping {self.id}>"
844
850
 
845
851
  @property
@@ -867,6 +873,7 @@ class Event(CObject):
867
873
  _unref_func = staticmethod(_capi.libeis.event_unref)
868
874
 
869
875
  def __repr__(self) -> str:
876
+ """The event type by name, or the raw value for one we do not model."""
870
877
  event_type = self.event_type
871
878
  label = event_type.name if isinstance(event_type, EventType) else event_type
872
879
  return f"<Event {label}>"
@@ -1079,6 +1086,11 @@ class Event(CObject):
1079
1086
 
1080
1087
 
1081
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
+ """
1082
1094
  # See ei.py's _log_callback: look up the raw int, not
1083
1095
  # _LogPriority(priority), which would raise ValueError before .get()'s
1084
1096
  # default could apply -- silently, since this runs inside a ctypes
@@ -1208,6 +1220,7 @@ class Eis(CObject):
1208
1220
 
1209
1221
  @classmethod
1210
1222
  def _new(cls) -> int:
1223
+ """A new server from the C library, or an error if it returned NULL."""
1211
1224
  pointer = _capi.libeis.new(c_void_p(None))
1212
1225
  if not pointer:
1213
1226
  raise Error("eis_new() returned NULL")
@@ -1265,6 +1278,10 @@ __all__ = [
1265
1278
  "KeyEvent",
1266
1279
  "Keymap",
1267
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",
1268
1285
  "Ping",
1269
1286
  "PointerAbsoluteEvent",
1270
1287
  "PointerEvent",
@@ -6,7 +6,7 @@ Negotiates an EIS connection through the
6
6
  This is the path a sandboxed or otherwise non-privileged client uses to get
7
7
  an EI socket: it asks the portal, the user is shown a consent dialog, and on
8
8
  approval this hands back a file descriptor to pass to
9
- :meth:`libei.ei.Sender.create_for_fd`.
9
+ :meth:`libei.ei.Sender.create_for_fd`::
10
10
 
11
11
  oeffis = Oeffis.create(devices=DeviceType.POINTER)
12
12
  while True:
@@ -41,6 +41,7 @@ import logging
41
41
  import os
42
42
 
43
43
  from . import _capi
44
+ from ._capi.loader import LibraryNotFoundError
44
45
 
45
46
  logger = logging.getLogger("libei.oeffis")
46
47
 
@@ -122,6 +123,11 @@ class Oeffis:
122
123
  self._state = _EventType.NONE
123
124
 
124
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
+ """
125
131
  # getattr() with defaults rather than plain attribute access:
126
132
  # __init__ raises DisconnectedError when oeffis_new() returns NULL,
127
133
  # and Python still calls __del__ on the half-built object, where
@@ -237,6 +243,11 @@ class Oeffis:
237
243
  __all__ = [
238
244
  "DeviceType",
239
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",
240
251
  "Oeffis",
241
252
  "SessionClosedError",
242
253
  "is_available",
@@ -21,7 +21,7 @@ should be handled by an application talking to DBus directly"
21
21
  (https://libinput.pages.freedesktop.org/libei/api/group__liboeffis.html).
22
22
  This module is that: the ``CreateSession`` -> ``SelectDevices`` -> ``Start``
23
23
  -> ``ConnectToEIS`` sequence driven directly, with ``persist_mode`` and
24
- ``restore_token`` exposed as real parameters.
24
+ ``restore_token`` exposed as real parameters::
25
25
 
26
26
  with RemoteDesktopSession.negotiate(
27
27
  devices=DeviceType.POINTER | DeviceType.KEYBOARD,
@@ -427,18 +427,21 @@ def _request(
427
427
  params: Any,
428
428
  *_a: Any,
429
429
  ) -> None:
430
+ """Record the first reply and quit the loop; later replies are ignored."""
430
431
  if result: # both subscriptions may fire; the first reply wins
431
432
  return
432
433
  result["code"], result["results"] = params.unpack()
433
434
  loop.quit()
434
435
 
435
436
  def on_timeout() -> bool:
437
+ """Stop the loop and record that it timed out rather than finished."""
436
438
  nonlocal timed_out
437
439
  timed_out = True
438
440
  loop.quit()
439
441
  return False # one-shot; GLib removes the source when this is False
440
442
 
441
443
  def subscribe(path: str) -> None:
444
+ """Subscribe to Response on one path, keeping the handle alive."""
442
445
  subscriptions.append(
443
446
  connection.signal_subscribe(
444
447
  busname,
@@ -685,12 +688,20 @@ class RemoteDesktopSession:
685
688
  self._connection = None
686
689
 
687
690
  def __enter__(self) -> RemoteDesktopSession:
691
+ """Return self: entering performs no action of its own."""
688
692
  return self
689
693
 
690
694
  def __exit__(self, *_exc: Any) -> None:
695
+ """Close the session on the way out, whatever happened."""
691
696
  self.close()
692
697
 
693
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
+ """
694
705
  # Deliberately only the fd, not the D-Bus half of close(): __del__
695
706
  # can run during interpreter shutdown, where a synchronous D-Bus
696
707
  # round trip may hang or fail in ways nothing can report. Closing an
@@ -1037,6 +1048,7 @@ def _wait_for_signal(
1037
1048
  params: Any,
1038
1049
  *_a: Any,
1039
1050
  ) -> None:
1051
+ """Record the first matching signal; anything else is logged and dropped."""
1040
1052
  if result: # a subscription that outlives its own wait can fire twice
1041
1053
  return
1042
1054
  args = params.unpack()
@@ -1058,6 +1070,7 @@ def _wait_for_signal(
1058
1070
  loop.quit()
1059
1071
 
1060
1072
  def on_timeout() -> bool:
1073
+ """Stop the loop and record that it timed out rather than finished."""
1061
1074
  nonlocal timed_out
1062
1075
  timed_out = True
1063
1076
  loop.quit()
@@ -1141,8 +1154,8 @@ class InputCaptureSession:
1141
1154
  direction: instead of injecting synthetic input, this receives real
1142
1155
  input from the user's own devices once the compositor decides to divert
1143
1156
  it here. That decision is the whole point of the protocol and is never
1144
- this session's to make -- see :meth:`enable` and :meth:`
1145
- wait_for_activation`.
1157
+ this session's to make -- see :meth:`enable` and
1158
+ :meth:`wait_for_activation`.
1146
1159
 
1147
1160
  **Capturing is exclusive.** Once the compositor activates a capture,
1148
1161
  the events it captures stop reaching the desktop entirely and are sent
@@ -1487,12 +1500,15 @@ class InputCaptureSession:
1487
1500
  self._connection = None
1488
1501
 
1489
1502
  def __enter__(self) -> InputCaptureSession:
1503
+ """Return self: entering performs no action of its own."""
1490
1504
  return self
1491
1505
 
1492
1506
  def __exit__(self, *_exc: Any) -> None:
1507
+ """Close the session on the way out, whatever happened."""
1493
1508
  self.close()
1494
1509
 
1495
1510
  def __del__(self) -> None:
1511
+ """Close the fd only, for the reason RemoteDesktopSession.__del__ gives."""
1496
1512
  # See RemoteDesktopSession.__del__ for why this closes only the fd.
1497
1513
  if getattr(self, "_eis_fd_claimed", True):
1498
1514
  return
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-libei
3
- Version: 0.5.2
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.2`), 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
@@ -394,8 +404,10 @@ a list of error messages. Start there when nothing happens.
394
404
  happens" checklist
395
405
  - [docs/vs-snegg.md](docs/vs-snegg.md) — how this differs from the reference
396
406
  bindings, and two signature issues found by cross-checking the C source
397
- - [docs/developers/](docs/developers/) — the four-layer architecture, and what
398
- 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
399
411
  - [CONTRIBUTING.md](CONTRIBUTING.md) — setup, checks, testing against an old
400
412
  libei, releasing
401
413
 
@@ -9,16 +9,24 @@ that documentation defects still get caught in that environment.
9
9
  from __future__ import annotations
10
10
 
11
11
  import ast
12
+ import importlib
12
13
  import re
13
14
  import textwrap
14
15
  from pathlib import Path
15
16
 
16
17
  import pytest
17
18
 
18
- from libei import ei, eis
19
+ from libei import ei, eis, oeffis, portal
19
20
 
20
21
  _PROJECT_ROOT = Path(__file__).resolve().parent.parent
21
22
  _README = _PROJECT_ROOT / "README.md"
23
+ _TROUBLESHOOTING = _PROJECT_ROOT / "docs" / "troubleshooting.md"
24
+
25
+ # The modules a reader can import a public name from -- what the reference
26
+ # pages name, and whose docstrings carry the examples worth checking.
27
+ _PUBLIC_MODULES = ("libei.ei", "libei.eis", "libei.oeffis", "libei.portal")
28
+
29
+ _EXCEPTION_HEADING = "## When it does raise: which name you are catching"
22
30
 
23
31
 
24
32
  def _readme_python_blocks() -> list[str]:
@@ -50,14 +58,22 @@ def test_readme_examples_are_valid_python() -> None:
50
58
  pytest.fail(f"README example is not valid Python ({exc}):\n{block}")
51
59
 
52
60
 
53
- @pytest.mark.parametrize("module", [ei, eis])
54
- def test_module_docstring_examples_are_valid_python(module: object) -> None:
61
+ @pytest.mark.parametrize("module_name", _PUBLIC_MODULES)
62
+ def test_module_docstring_examples_are_valid_python(module_name: str) -> None:
63
+ # Every module with an example has to write it as the ``::`` literal
64
+ # block the extractor reads, and the block has to parse. oeffis and
65
+ # portal wrote theirs as a plain indented block, so neither example was
66
+ # ever extracted here -- or evaluated as reST, where a literal block
67
+ # needs the marker.
68
+ module = importlib.import_module(module_name)
55
69
  source = _docstring_example(module.__doc__ or "")
56
- assert source.strip(), f"{module.__name__} docstring has no example" # type: ignore[attr-defined]
70
+ assert source.strip(), f"{module_name} docstring has no ``::`` example"
57
71
  try:
58
- compile(source, f"<{module.__name__} docstring>", "exec") # type: ignore[attr-defined]
72
+ compile(source, f"<{module_name} docstring>", "exec")
59
73
  except SyntaxError as exc:
60
- pytest.fail(f"docstring example is not valid Python ({exc}):\n{source}")
74
+ pytest.fail(
75
+ f"{module_name} docstring example is not valid Python ({exc}):\n{source}"
76
+ )
61
77
 
62
78
 
63
79
  def _emulating_examples() -> list[str]:
@@ -295,8 +311,33 @@ def test_relative_links_point_at_something_that_exists() -> None:
295
311
  path = target.split("#", 1)[0]
296
312
  if not path:
297
313
  continue
298
- assert (page.parent / path).exists(), (
299
- f"{page.name} links to {path}, which does not exist"
314
+ linked = page.parent / path
315
+ assert linked.exists(), f"{page.name} links to {path}, which does not exist"
316
+ if linked.is_dir():
317
+ # A directory is a page only where it has a README. Without one
318
+ # the link lands the reader on a file listing, which is what
319
+ # the README's own links to `docs/developers/` did until they
320
+ # named the two pages in it -- and `exists()` alone cannot tell
321
+ # the two apart.
322
+ assert (linked / "README.md").exists(), (
323
+ f"{page.name} links to the directory {path}, which has no "
324
+ "README to land on"
325
+ )
326
+
327
+
328
+ def test_every_public_name_is_named_on_some_page() -> None:
329
+ # A name in a module's __all__ is its declared surface, and these pages are
330
+ # where a reader looks for it: three names were public and reached no page
331
+ # at all, so the only place to learn they existed was the source they are
332
+ # defined in. Read from __all__ rather than dir(), since what is being
333
+ # guarded is "declared public", not "happens to have an attribute".
334
+ text = "\n".join(
335
+ page.read_text(encoding="utf-8") for page in _documentation_pages()
336
+ )
337
+ for module in (ei, eis, oeffis, portal):
338
+ for name in module.__all__:
339
+ assert name in text, (
340
+ f"{module.__name__}.{name} is public but named on no page"
300
341
  )
301
342
 
302
343
 
@@ -314,12 +355,124 @@ def test_each_pages_own_contents_resolves() -> None:
314
355
  )
315
356
 
316
357
 
317
- def test_readme_status_names_the_current_version() -> None:
318
- # The Status section states the version in prose; a bump that leaves it
319
- # behind is how a README starts describing a release that no longer
320
- # exists.
358
+ @pytest.mark.parametrize("page", ["README.md", "docs/developers/verification.md"])
359
+ def test_status_pages_name_the_current_version(page: str) -> None:
360
+ # Both state the version in prose, and nothing keeps them in step with
361
+ # pyproject.toml -- so a bump that touches one and not the other ships a
362
+ # page describing a release that no longer exists. The developers page
363
+ # sat a whole release behind exactly that way: 0.5.1 while
364
+ # pyproject.toml, libei.__version__ and the README all said 0.5.2.
321
365
  import libei
322
366
 
323
- assert f"`{libei.__version__}`" in _README.read_text(), (
324
- f"README does not mention the current version {libei.__version__}"
367
+ assert f"`{libei.__version__}`" in (_PROJECT_ROOT / page).read_text(), (
368
+ f"{page} does not mention the current version {libei.__version__}"
325
369
  )
370
+
371
+
372
+ def _documented_exceptions() -> list[tuple[list[str], list[str]]]:
373
+ """The (modules, classes) of each row of the exception table.
374
+
375
+ Only the table under :data:`_EXCEPTION_HEADING` is read, so a table
376
+ added to that page elsewhere cannot quietly become part of this check.
377
+ """
378
+ _, _, after = _TROUBLESHOOTING.read_text().partition(_EXCEPTION_HEADING)
379
+ section = after.split("\n## ", 1)[0]
380
+ rows: list[tuple[list[str], list[str]]] = []
381
+ for line in section.splitlines():
382
+ if not line.startswith("|"):
383
+ continue
384
+ cells = [cell.strip() for cell in line.strip("|").split("|")]
385
+ if len(cells) < 2 or cells[0] in ("Module", "") or cells[0].strip("- ") == "":
386
+ continue
387
+ rows.append(
388
+ (
389
+ [name.strip(" `") for name in cells[0].split(",")],
390
+ [name.strip(" `") for name in cells[1].split(",")],
391
+ )
392
+ )
393
+ return rows
394
+
395
+
396
+ def test_documented_exceptions_are_part_of_the_public_api() -> None:
397
+ # The page says which module to import each exception from, so each has
398
+ # to be part of that module's declared surface rather than an attribute
399
+ # it happens to carry. LibraryNotFoundError was importable from
400
+ # libei.ei and libei.eis but listed in neither __all__ -- which left the
401
+ # private libei._capi.loader as the only place it was obviously public.
402
+ rows = _documented_exceptions()
403
+ assert rows, "no exception table found in docs/troubleshooting.md"
404
+ for modules, classes in rows:
405
+ for class_name in classes:
406
+ found: dict[str, object] = {}
407
+ for module_name in modules:
408
+ module = importlib.import_module(module_name)
409
+ assert hasattr(module, class_name), f"{module_name} has no {class_name}"
410
+ assert class_name in module.__all__, (
411
+ f"{module_name}.{class_name} is documented as importable from "
412
+ "there, but is missing from that module's __all__"
413
+ )
414
+ found[module_name] = getattr(module, class_name)
415
+ # A row may name two modules for one class, and Error is a
416
+ # separate class on each side -- so a shared row is the only
417
+ # thing that has to resolve to one object.
418
+ assert len({id(cls) for cls in found.values()}) == 1, (
419
+ f"the row for {class_name} names {modules}, which do not agree "
420
+ "on what that class is"
421
+ )
422
+
423
+
424
+ @pytest.mark.parametrize("module_name", _PUBLIC_MODULES)
425
+ def test_public_api_is_documented(module_name: str) -> None:
426
+ # The package ships py.typed and is meant to be consumed as a
427
+ # dependency, so every public callable needs at least a one-line
428
+ # docstring. This started at 3/64 and 6/74. It reads source text and
429
+ # needs nothing installed, so it belongs here rather than in
430
+ # test_documented_examples.py, whose module-level `integration` mark
431
+ # skipped it on exactly the machines where it was cheapest to run.
432
+ module = importlib.import_module(module_name)
433
+ source_path = module.__file__
434
+ assert source_path is not None, f"{module_name} has no source file to read"
435
+ tree = ast.parse(Path(source_path).read_text())
436
+ undocumented = [
437
+ node.name
438
+ for node in ast.walk(tree)
439
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))
440
+ and not node.name.startswith("_")
441
+ and not ast.get_docstring(node)
442
+ ]
443
+ assert not undocumented, (
444
+ f"{module_name} has undocumented public definitions: "
445
+ f"{sorted(set(undocumented))}"
446
+ )
447
+
448
+
449
+ # A role whose name starts on the next line renders as literal text wherever
450
+ # the docstring is read, and so does everything after an unpaired backtick.
451
+ _SPLIT_ROLE = re.compile(r":(class|meth|attr|func|mod|data|exc):`[^`\n]*\n")
452
+
453
+
454
+ def _docstrings(module_name: str) -> list[tuple[str, str]]:
455
+ """The (where, docstring) of a module and every definition inside it."""
456
+ module = importlib.import_module(module_name)
457
+ source_path = module.__file__
458
+ assert source_path is not None, f"{module_name} has no source file to read"
459
+ tree = ast.parse(Path(source_path).read_text())
460
+ found = [("<module>", ast.get_docstring(tree) or "")]
461
+ for node in ast.walk(tree):
462
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
463
+ found.append((node.name, ast.get_docstring(node) or ""))
464
+ return found
465
+
466
+
467
+ @pytest.mark.parametrize("module_name", _PUBLIC_MODULES)
468
+ def test_docstrings_have_no_broken_markup(module_name: str) -> None:
469
+ # Neither defect shows up anywhere except in the rendered text: portal.py
470
+ # read ":meth:`enable` and :meth:`" and broke the line before
471
+ # "wait_for_activation`", so the second name never became a link, and a
472
+ # docstring with an unpaired backtick silently swallows the rest of it.
473
+ for where, doc in _docstrings(module_name):
474
+ if _SPLIT_ROLE.search(doc) is not None:
475
+ pytest.fail(f"{module_name}:{where} breaks a role across a line break")
476
+ assert doc.count("`") % 2 == 0, (
477
+ f"{module_name}:{where} has an unpaired backtick"
478
+ )
@@ -90,30 +90,3 @@ def test_ei_module_docstring_example_runs() -> None:
90
90
  ei.Context.dispatch = original_dispatch # type: ignore[method-assign]
91
91
 
92
92
  assert namespace["device"] is not None
93
-
94
-
95
- @pytest.mark.parametrize("module", [ei, eis, "oeffis"])
96
- def test_public_api_is_documented(module: object) -> None:
97
- # The package ships py.typed and is meant to be consumed as a
98
- # dependency, so every public callable needs at least a one-line
99
- # docstring. This started at 3/64 and 6/74.
100
- import ast
101
- import importlib
102
-
103
- if isinstance(module, str):
104
- module = importlib.import_module(f"libei.{module}")
105
-
106
- source = Path(module.__file__).read_text() # type: ignore[attr-defined]
107
- tree = ast.parse(source)
108
-
109
- undocumented = [
110
- node.name
111
- for node in ast.walk(tree)
112
- if isinstance(node, ast.FunctionDef)
113
- and not node.name.startswith("_")
114
- and not ast.get_docstring(node)
115
- ]
116
- assert not undocumented, (
117
- f"{module.__name__} has undocumented public callables: " # type: ignore[attr-defined]
118
- f"{sorted(set(undocumented))}"
119
- )
@@ -489,6 +489,20 @@ def test_explicit_all_devices_is_translated_too() -> None:
489
489
  assert select[1][-1]["types"].value != 0
490
490
 
491
491
 
492
+ def test_create_session_carries_a_session_handle_token() -> None:
493
+ # Omitting it crashes xdg-desktop-portal 1.22.1 outright -- SIGABRT,
494
+ # "assertion failed: (session->token != NULL)" -- and it is a *different*
495
+ # token from the handle_token _request() injects on its own, so a
496
+ # CreateSession that carries one and not the other still looks right.
497
+ connection = FakeConnection()
498
+ with install_fake_gi(connection):
499
+ portal.RemoteDesktopSession.negotiate(connection=connection)
500
+ create = next(c for c in connection.calls if c[0] == "CreateSession")
501
+ options = create[1][-1]
502
+ assert options["session_handle_token"].value
503
+ assert options["handle_token"].value
504
+
505
+
492
506
  def test_restore_token_without_persist_mode_is_refused() -> None:
493
507
  # The portal consumes a restore token on use and only mints a new one
494
508
  # when persistence was asked for, so this combination would spend the
File without changes
File without changes