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.
- {python_libei-0.5.2 → python_libei-0.6.0}/PKG-INFO +22 -10
- {python_libei-0.5.2 → python_libei-0.6.0}/README.md +21 -9
- {python_libei-0.5.2 → python_libei-0.6.0}/pyproject.toml +9 -1
- {python_libei-0.5.2 → python_libei-0.6.0}/src/libei/__init__.py +1 -1
- {python_libei-0.5.2 → python_libei-0.6.0}/src/libei/_capi/loader.py +7 -0
- {python_libei-0.5.2 → python_libei-0.6.0}/src/libei/_cobject.py +6 -0
- {python_libei-0.5.2 → python_libei-0.6.0}/src/libei/ei.py +19 -0
- {python_libei-0.5.2 → python_libei-0.6.0}/src/libei/eis.py +17 -0
- {python_libei-0.5.2 → python_libei-0.6.0}/src/libei/oeffis.py +12 -1
- {python_libei-0.5.2 → python_libei-0.6.0}/src/libei/portal.py +19 -3
- {python_libei-0.5.2 → python_libei-0.6.0}/src/python_libei.egg-info/PKG-INFO +22 -10
- {python_libei-0.5.2 → python_libei-0.6.0}/tests/test_documentation_shape.py +167 -14
- {python_libei-0.5.2 → python_libei-0.6.0}/tests/test_documented_examples.py +0 -27
- {python_libei-0.5.2 → python_libei-0.6.0}/tests/test_portal.py +14 -0
- {python_libei-0.5.2 → python_libei-0.6.0}/LICENSE +0 -0
- {python_libei-0.5.2 → python_libei-0.6.0}/setup.cfg +0 -0
- {python_libei-0.5.2 → python_libei-0.6.0}/src/libei/_capi/__init__.py +0 -0
- {python_libei-0.5.2 → python_libei-0.6.0}/src/libei/_capi/libei.py +0 -0
- {python_libei-0.5.2 → python_libei-0.6.0}/src/libei/_capi/libeis.py +0 -0
- {python_libei-0.5.2 → python_libei-0.6.0}/src/libei/_capi/liboeffis.py +0 -0
- {python_libei-0.5.2 → python_libei-0.6.0}/src/libei/py.typed +0 -0
- {python_libei-0.5.2 → python_libei-0.6.0}/src/python_libei.egg-info/SOURCES.txt +0 -0
- {python_libei-0.5.2 → python_libei-0.6.0}/src/python_libei.egg-info/dependency_links.txt +0 -0
- {python_libei-0.5.2 → python_libei-0.6.0}/src/python_libei.egg-info/requires.txt +0 -0
- {python_libei-0.5.2 → python_libei-0.6.0}/src/python_libei.egg-info/top_level.txt +0 -0
- {python_libei-0.5.2 → python_libei-0.6.0}/tests/test_cobject.py +0 -0
- {python_libei-0.5.2 → python_libei-0.6.0}/tests/test_ei_objects.py +0 -0
- {python_libei-0.5.2 → python_libei-0.6.0}/tests/test_eis_objects.py +0 -0
- {python_libei-0.5.2 → python_libei-0.6.0}/tests/test_inputcapture.py +0 -0
- {python_libei-0.5.2 → python_libei-0.6.0}/tests/test_integration_extras.py +0 -0
- {python_libei-0.5.2 → python_libei-0.6.0}/tests/test_integration_socketpair.py +0 -0
- {python_libei-0.5.2 → python_libei-0.6.0}/tests/test_loader.py +0 -0
- {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.
|
|
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
|
+
[](https://github.com/ctrondlp/python-libei/actions/workflows/ci.yml)
|
|
41
|
+
[](https://pypi.org/project/python-libei/)
|
|
42
|
+
[](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()
|
|
107
|
-
`
|
|
108
|
-
`
|
|
109
|
-
|
|
110
|
-
|
|
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()`
|
|
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.
|
|
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/)
|
|
398
|
-
|
|
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
|
+
[](https://github.com/ctrondlp/python-libei/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/python-libei/)
|
|
5
|
+
[](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()
|
|
70
|
-
`
|
|
71
|
-
`
|
|
72
|
-
|
|
73
|
-
|
|
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()`
|
|
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.
|
|
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/)
|
|
361
|
-
|
|
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.
|
|
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
|
]
|
|
@@ -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
|
|
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.
|
|
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
|
+
[](https://github.com/ctrondlp/python-libei/actions/workflows/ci.yml)
|
|
41
|
+
[](https://pypi.org/project/python-libei/)
|
|
42
|
+
[](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()
|
|
107
|
-
`
|
|
108
|
-
`
|
|
109
|
-
|
|
110
|
-
|
|
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()`
|
|
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.
|
|
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/)
|
|
398
|
-
|
|
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("
|
|
54
|
-
def test_module_docstring_examples_are_valid_python(
|
|
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"{
|
|
70
|
+
assert source.strip(), f"{module_name} docstring has no ``::`` example"
|
|
57
71
|
try:
|
|
58
|
-
compile(source, f"<{
|
|
72
|
+
compile(source, f"<{module_name} docstring>", "exec")
|
|
59
73
|
except SyntaxError as exc:
|
|
60
|
-
pytest.fail(
|
|
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
|
-
|
|
299
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
#
|
|
320
|
-
#
|
|
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
|
|
324
|
-
f"
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|