python-libei 0.5.0__tar.gz → 0.5.1__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.0 → python_libei-0.5.1}/PKG-INFO +2 -2
  2. {python_libei-0.5.0 → python_libei-0.5.1}/README.md +1 -1
  3. {python_libei-0.5.0 → python_libei-0.5.1}/pyproject.toml +1 -1
  4. {python_libei-0.5.0 → python_libei-0.5.1}/src/libei/__init__.py +1 -1
  5. {python_libei-0.5.0 → python_libei-0.5.1}/src/libei/portal.py +133 -46
  6. {python_libei-0.5.0 → python_libei-0.5.1}/src/python_libei.egg-info/PKG-INFO +2 -2
  7. {python_libei-0.5.0 → python_libei-0.5.1}/tests/test_inputcapture.py +51 -6
  8. {python_libei-0.5.0 → python_libei-0.5.1}/LICENSE +0 -0
  9. {python_libei-0.5.0 → python_libei-0.5.1}/setup.cfg +0 -0
  10. {python_libei-0.5.0 → python_libei-0.5.1}/src/libei/_capi/__init__.py +0 -0
  11. {python_libei-0.5.0 → python_libei-0.5.1}/src/libei/_capi/libei.py +0 -0
  12. {python_libei-0.5.0 → python_libei-0.5.1}/src/libei/_capi/libeis.py +0 -0
  13. {python_libei-0.5.0 → python_libei-0.5.1}/src/libei/_capi/liboeffis.py +0 -0
  14. {python_libei-0.5.0 → python_libei-0.5.1}/src/libei/_capi/loader.py +0 -0
  15. {python_libei-0.5.0 → python_libei-0.5.1}/src/libei/_cobject.py +0 -0
  16. {python_libei-0.5.0 → python_libei-0.5.1}/src/libei/ei.py +0 -0
  17. {python_libei-0.5.0 → python_libei-0.5.1}/src/libei/eis.py +0 -0
  18. {python_libei-0.5.0 → python_libei-0.5.1}/src/libei/oeffis.py +0 -0
  19. {python_libei-0.5.0 → python_libei-0.5.1}/src/libei/py.typed +0 -0
  20. {python_libei-0.5.0 → python_libei-0.5.1}/src/python_libei.egg-info/SOURCES.txt +0 -0
  21. {python_libei-0.5.0 → python_libei-0.5.1}/src/python_libei.egg-info/dependency_links.txt +0 -0
  22. {python_libei-0.5.0 → python_libei-0.5.1}/src/python_libei.egg-info/requires.txt +0 -0
  23. {python_libei-0.5.0 → python_libei-0.5.1}/src/python_libei.egg-info/top_level.txt +0 -0
  24. {python_libei-0.5.0 → python_libei-0.5.1}/tests/test_cobject.py +0 -0
  25. {python_libei-0.5.0 → python_libei-0.5.1}/tests/test_documentation_shape.py +0 -0
  26. {python_libei-0.5.0 → python_libei-0.5.1}/tests/test_documented_examples.py +0 -0
  27. {python_libei-0.5.0 → python_libei-0.5.1}/tests/test_ei_objects.py +0 -0
  28. {python_libei-0.5.0 → python_libei-0.5.1}/tests/test_eis_objects.py +0 -0
  29. {python_libei-0.5.0 → python_libei-0.5.1}/tests/test_integration_extras.py +0 -0
  30. {python_libei-0.5.0 → python_libei-0.5.1}/tests/test_integration_socketpair.py +0 -0
  31. {python_libei-0.5.0 → python_libei-0.5.1}/tests/test_loader.py +0 -0
  32. {python_libei-0.5.0 → python_libei-0.5.1}/tests/test_oeffis.py +0 -0
  33. {python_libei-0.5.0 → python_libei-0.5.1}/tests/test_portal.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-libei
3
- Version: 0.5.0
3
+ Version: 0.5.1
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
@@ -156,7 +156,7 @@ and why, is in
156
156
 
157
157
  ## Status
158
158
 
159
- Beta (`0.5.0`), published on [PyPI](https://pypi.org/project/python-libei/)
159
+ Beta (`0.5.1`), published on [PyPI](https://pypi.org/project/python-libei/)
160
160
  since `0.1.0`, and **the API is not frozen** — expect renames before 1.0.
161
161
 
162
162
  The injection path is exercised end to end against the real libraries by the
@@ -119,7 +119,7 @@ and why, is in
119
119
 
120
120
  ## Status
121
121
 
122
- Beta (`0.5.0`), published on [PyPI](https://pypi.org/project/python-libei/)
122
+ Beta (`0.5.1`), published on [PyPI](https://pypi.org/project/python-libei/)
123
123
  since `0.1.0`, and **the API is not frozen** — expect renames before 1.0.
124
124
 
125
125
  The injection path is exercised end to end against the real libraries by the
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-libei"
7
- version = "0.5.0"
7
+ version = "0.5.1"
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"
@@ -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.0"
34
+ __version__ = "0.5.1"
35
35
 
36
36
  __all__ = ["__version__"]
@@ -926,6 +926,53 @@ def _input_capture_version(
926
926
  return int(version)
927
927
 
928
928
 
929
+ def _create_input_capture_session_v1(
930
+ connection: Any,
931
+ Gio: Any,
932
+ GLib: Any,
933
+ busname: str,
934
+ types: int,
935
+ timeout: float,
936
+ ) -> str:
937
+ """Create an InputCapture session on a portal that predates v2.
938
+
939
+ v1 has no ``Start`` at all -- that method, like ``CreateSession2``, was
940
+ added in version 2. Here ``CreateSession`` itself carries
941
+ ``capabilities`` and raises the consent dialog, and the session is ready
942
+ for ``GetZones`` / ``SetPointerBarriers`` / ``Enable`` the moment its
943
+ ``Response`` arrives. ``ConnectToEIS`` is a v1 original, so the rest of
944
+ this class works against such a portal unchanged.
945
+
946
+ Returns the session handle. ``persist_mode`` and ``restore_token`` have
947
+ no v1 equivalent -- both are documented as version 2 additions to
948
+ ``Start``, which does not exist here -- so the caller must reject them
949
+ before getting this far rather than have them silently ignored.
950
+ """
951
+ code, results = _request(
952
+ connection,
953
+ Gio,
954
+ GLib,
955
+ busname,
956
+ _INPUT_CAPTURE,
957
+ "CreateSession",
958
+ "(sa{sv})",
959
+ ("",),
960
+ {
961
+ "session_handle_token": GLib.Variant("s", uuid.uuid4().hex),
962
+ "capabilities": GLib.Variant("u", int(types)),
963
+ },
964
+ timeout,
965
+ )
966
+ if code != 0:
967
+ raise PortalDeniedError(
968
+ "CreateSession", "the user declined the input-capture consent dialog"
969
+ )
970
+ session_handle = results.get("session_handle")
971
+ if not isinstance(session_handle, str):
972
+ raise PortalError("CreateSession returned no session_handle")
973
+ return session_handle
974
+
975
+
929
976
  def _wait_for_signal(
930
977
  connection: Any,
931
978
  Gio: Any,
@@ -1392,11 +1439,18 @@ class InputCaptureSession:
1392
1439
  Blocks until ``CreateSession2`` -> ``Start`` -> ``ConnectToEIS``
1393
1440
  resolves, prompting the user for consent along the way unless
1394
1441
  ``restore_token`` lets the portal skip that. Raises
1395
- :class:`PortalVersionError` if the compositor's InputCapture portal
1396
- predates ``CreateSession2`` (needs v2+ -- this module never speaks
1397
- the deprecated v1 ``CreateSession``), :class:`PortalDeniedError` if
1398
- ``Start`` is declined, and :class:`PortalTimeoutError` if any one
1399
- round trip exceeds ``timeout`` seconds.
1442
+ :class:`PortalDeniedError` if consent is declined, and
1443
+ :class:`PortalTimeoutError` if any one round trip exceeds ``timeout``
1444
+ seconds.
1445
+
1446
+ A portal older than v2 has neither ``CreateSession2`` nor ``Start``,
1447
+ and is negotiated through the deprecated v1 ``CreateSession``
1448
+ instead -- one call that carries ``capabilities`` and raises the
1449
+ consent dialog itself. Everything after negotiation is identical;
1450
+ ``ConnectToEIS`` is a v1 original. The one thing v1 cannot do is
1451
+ persist: ``persist_mode`` and ``restore_token`` were added to
1452
+ ``Start``, so passing either against such a portal raises
1453
+ :class:`PortalVersionError` rather than being quietly ignored.
1400
1454
 
1401
1455
  Returns before anything is actually captured: :meth:`set_pointer_barriers`
1402
1456
  and :meth:`enable` still have to be called, and even then nothing
@@ -1435,62 +1489,95 @@ class InputCaptureSession:
1435
1489
  raise PortalError(f"cannot reach the session bus: {exc}") from exc
1436
1490
 
1437
1491
  version = _input_capture_version(connection, Gio, GLib, busname, timeout)
1438
- if version < _MIN_INPUT_CAPTURE_VERSION:
1492
+ # A version below 2 is not a portal to give up on -- it is a portal
1493
+ # that speaks the older half of the same interface. Only
1494
+ # `CreateSession2` and `Start` are version 2 additions; `GetZones`,
1495
+ # `SetPointerBarriers`, `Enable`, `Disable`, `Release` and, crucially,
1496
+ # `ConnectToEIS` are all v1 originals, so everything this class does
1497
+ # after negotiation works either way and only session creation forks.
1498
+ #
1499
+ # This is not hypothetical. xdg-desktop-portal-gnome 50 reports
1500
+ # version 0 -- it registers the full impl interface and never sets the
1501
+ # property -- and calling `CreateSession2` against it fails with
1502
+ # `UnknownMethod`, while v1 `CreateSession` is dispatched normally.
1503
+ # Note that its introspection XML advertises `CreateSession2` anyway:
1504
+ # that XML is static, so it says nothing about what the frontend will
1505
+ # actually dispatch. The `version` property is the only usable signal,
1506
+ # which is why it is read first and believed.
1507
+ legacy = version < _MIN_INPUT_CAPTURE_VERSION
1508
+
1509
+ if legacy and (persist_mode != PersistMode.NONE or restore_token is not None):
1439
1510
  raise PortalVersionError(
1440
- f"InputCapture version {version} is too old for "
1441
- f"CreateSession2 (need {_MIN_INPUT_CAPTURE_VERSION}+)"
1511
+ f"InputCapture version {version} has no Start method, and "
1512
+ f"persist_mode/restore_token are version "
1513
+ f"{_MIN_INPUT_CAPTURE_VERSION} additions to it -- this portal "
1514
+ f"cannot persist a session. Retry with persist_mode=NONE and "
1515
+ f"no restore_token to negotiate a one-shot session."
1442
1516
  )
1443
1517
 
1444
1518
  if capabilities == DeviceType.ALL_DEVICES:
1445
1519
  types = _ALL_DEVICE_TYPES
1446
1520
  else:
1447
1521
  types = capabilities
1448
- reply = _call_sync(
1449
- connection,
1450
- Gio,
1451
- GLib,
1452
- busname,
1453
- _OBJECT_PATH,
1454
- _INPUT_CAPTURE,
1455
- "CreateSession2",
1456
- GLib.Variant(
1457
- "(a{sv})",
1458
- ({"session_handle_token": GLib.Variant("s", uuid.uuid4().hex)},),
1459
- ),
1460
- None,
1461
- int(timeout * 1000),
1462
- )
1463
- (results,) = reply.unpack()
1464
- session_handle = results.get("session_handle")
1465
- if not isinstance(session_handle, str):
1466
- raise PortalError("CreateSession2 returned no session_handle")
1467
1522
 
1468
- # Past this point a session exists inside xdg-desktop-portal, and
1469
- # nothing else can close it -- see RemoteDesktopSession.negotiate's
1470
- # identical reasoning, which this mirrors line for line.
1471
- try:
1472
- options: dict[str, Any] = {"capabilities": GLib.Variant("u", int(types))}
1473
- if persist_mode != PersistMode.NONE:
1474
- options["persist_mode"] = GLib.Variant("u", int(persist_mode))
1475
- if restore_token is not None:
1476
- options["restore_token"] = GLib.Variant("s", restore_token)
1477
- code, start_results = _request(
1523
+ if legacy:
1524
+ session_handle = _create_input_capture_session_v1(
1525
+ connection, Gio, GLib, busname, int(types), timeout
1526
+ )
1527
+ else:
1528
+ reply = _call_sync(
1478
1529
  connection,
1479
1530
  Gio,
1480
1531
  GLib,
1481
1532
  busname,
1533
+ _OBJECT_PATH,
1482
1534
  _INPUT_CAPTURE,
1483
- "Start",
1484
- "(osa{sv})",
1485
- (session_handle, ""),
1486
- options,
1487
- timeout,
1535
+ "CreateSession2",
1536
+ GLib.Variant(
1537
+ "(a{sv})",
1538
+ ({"session_handle_token": GLib.Variant("s", uuid.uuid4().hex)},),
1539
+ ),
1540
+ None,
1541
+ int(timeout * 1000),
1488
1542
  )
1489
- if code != 0:
1490
- raise PortalDeniedError(
1491
- "Start", "the user declined the input-capture consent dialog"
1543
+ (results,) = reply.unpack()
1544
+ session_handle = results.get("session_handle")
1545
+ if not isinstance(session_handle, str):
1546
+ raise PortalError("CreateSession2 returned no session_handle")
1547
+
1548
+ # Past this point a session exists inside xdg-desktop-portal, and
1549
+ # nothing else can close it -- see RemoteDesktopSession.negotiate's
1550
+ # identical reasoning, which this mirrors line for line.
1551
+ try:
1552
+ # v1 asked for consent and capabilities in CreateSession itself,
1553
+ # and has no Start to call; it can therefore never issue a
1554
+ # restore token either.
1555
+ new_restore_token = None
1556
+ if not legacy:
1557
+ options: dict[str, Any] = {
1558
+ "capabilities": GLib.Variant("u", int(types))
1559
+ }
1560
+ if persist_mode != PersistMode.NONE:
1561
+ options["persist_mode"] = GLib.Variant("u", int(persist_mode))
1562
+ if restore_token is not None:
1563
+ options["restore_token"] = GLib.Variant("s", restore_token)
1564
+ code, start_results = _request(
1565
+ connection,
1566
+ Gio,
1567
+ GLib,
1568
+ busname,
1569
+ _INPUT_CAPTURE,
1570
+ "Start",
1571
+ "(osa{sv})",
1572
+ (session_handle, ""),
1573
+ options,
1574
+ timeout,
1492
1575
  )
1493
- new_restore_token = start_results.get("restore_token")
1576
+ if code != 0:
1577
+ raise PortalDeniedError(
1578
+ "Start", "the user declined the input-capture consent dialog"
1579
+ )
1580
+ new_restore_token = start_results.get("restore_token")
1494
1581
 
1495
1582
  eis_fd = _call_for_fd(
1496
1583
  connection,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-libei
3
- Version: 0.5.0
3
+ Version: 0.5.1
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
@@ -156,7 +156,7 @@ and why, is in
156
156
 
157
157
  ## Status
158
158
 
159
- Beta (`0.5.0`), published on [PyPI](https://pypi.org/project/python-libei/)
159
+ Beta (`0.5.1`), published on [PyPI](https://pypi.org/project/python-libei/)
160
160
  since `0.1.0`, and **the API is not frozen** — expect renames before 1.0.
161
161
 
162
162
  The injection path is exercised end to end against the real libraries by the
@@ -46,8 +46,8 @@ class FakeInputCaptureConnection:
46
46
 
47
47
  Three call shapes, told apart by method name: `Get` (the version
48
48
  property), `CreateSession2`/`Enable`/`Disable`/`Release` (plain calls,
49
- no Request/Response), and `Start`/`GetZones`/`SetPointerBarriers`
50
- (Request-shaped, exactly like RemoteDesktop's own calls -- the
49
+ no Request/Response), and `CreateSession` (v1)/`Start`/`GetZones`/
50
+ `SetPointerBarriers` (Request-shaped, exactly like RemoteDesktop's -- the
51
51
  `handle_token` lives in whichever positional argument is a dict, found
52
52
  by type rather than by a fixed index, since SetPointerBarriers's
53
53
  options argument is not last).
@@ -78,6 +78,9 @@ class FakeInputCaptureConnection:
78
78
  self.session_handle = session_handle
79
79
  self.responses = responses or {
80
80
  "Start": (0, {"capabilities": 3}),
81
+ # v1 CreateSession is Request-shaped too, and answers with the
82
+ # handle CreateSession2 returns directly.
83
+ "CreateSession": (0, {"session_handle": session_handle}),
81
84
  "GetZones": (0, {"zone_set": 1, "zones": [(1920, 1080, 0, 0)]}),
82
85
  "SetPointerBarriers": (0, {"failed_barriers": []}),
83
86
  }
@@ -209,17 +212,59 @@ def test_successful_negotiation_calls_every_step_in_order() -> None:
209
212
  assert session.session_handle == connection.session_handle
210
213
 
211
214
 
212
- def test_old_input_capture_version_is_refused() -> None:
215
+ @pytest.mark.parametrize("version", [1, 0])
216
+ def test_pre_v2_portal_negotiates_through_v1_create_session(version: int) -> None:
217
+ """v1 has no CreateSession2 and no Start -- one call does both jobs.
218
+
219
+ version 0 takes the same path: xdg-desktop-portal-gnome 50 registers the
220
+ whole impl interface and never sets the property, so 0 reaches us from a
221
+ portal that speaks v1 perfectly well.
222
+ """
223
+ connection = FakeInputCaptureConnection(version=version)
224
+ with install_fake_gi(connection):
225
+ session = portal.InputCaptureSession.negotiate(connection=connection)
226
+ assert [c[0] for c in connection.calls] == [
227
+ "Get",
228
+ "CreateSession",
229
+ "ConnectToEIS",
230
+ ]
231
+ assert session.session_handle == connection.session_handle
232
+ assert session.eis_fd == connection.fd_responses["ConnectToEIS"]
233
+ # v1 can never issue one: restore_token is a v2 addition to Start.
234
+ assert session.restore_token is None
235
+ # capabilities ride on CreateSession here, not on Start.
236
+ options = next(v for v in connection.calls[1][1] if isinstance(v, dict))
237
+ assert options["capabilities"].value == int(portal._ALL_DEVICE_TYPES)
238
+
239
+
240
+ def test_v1_portal_refuses_persistence_rather_than_ignoring_it() -> None:
241
+ """persist_mode/restore_token are v2 additions to a method v1 lacks."""
213
242
  connection = FakeInputCaptureConnection(version=1)
214
243
  with install_fake_gi(connection):
215
- with pytest.raises(portal.PortalVersionError):
216
- portal.InputCaptureSession.negotiate(connection=connection)
217
- # Refused before ever calling CreateSession2.
244
+ with pytest.raises(portal.PortalVersionError, match="cannot persist"):
245
+ portal.InputCaptureSession.negotiate(
246
+ connection=connection, persist_mode=portal.PersistMode.UNTIL_REVOKED
247
+ )
248
+ # Refused before creating anything that would need cleaning up.
218
249
  assert connection.calls == [
219
250
  ("Get", ("org.freedesktop.portal.InputCapture", "version"))
220
251
  ]
221
252
 
222
253
 
254
+ def test_declined_v1_create_session_raises_denied_error() -> None:
255
+ """v1 raises its consent dialog in CreateSession, so that is the step."""
256
+ connection = FakeInputCaptureConnection(
257
+ version=1,
258
+ responses={"CreateSession": (1, {})}, # 1 == user cancelled
259
+ )
260
+ with install_fake_gi(connection):
261
+ with pytest.raises(portal.PortalDeniedError) as excinfo:
262
+ portal.InputCaptureSession.negotiate(connection=connection)
263
+ assert excinfo.value.step == "CreateSession"
264
+ # No session handle came back, so there is nothing to Close.
265
+ assert [c[0] for c in connection.calls] == ["Get", "CreateSession"]
266
+
267
+
223
268
  def test_a_missing_session_handle_raises_portal_error() -> None:
224
269
  class NoHandleConnection(FakeInputCaptureConnection):
225
270
  def call_sync(self, *args: Any, **kwargs: Any) -> FakeReply:
File without changes
File without changes