python-libei 0.6.1__tar.gz → 0.7.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 (37) hide show
  1. {python_libei-0.6.1/src/python_libei.egg-info → python_libei-0.7.0}/PKG-INFO +3 -2
  2. {python_libei-0.6.1 → python_libei-0.7.0}/README.md +2 -1
  3. {python_libei-0.6.1 → python_libei-0.7.0}/pyproject.toml +1 -1
  4. {python_libei-0.6.1 → python_libei-0.7.0}/src/libei/__init__.py +1 -1
  5. {python_libei-0.6.1 → python_libei-0.7.0}/src/libei/portal.py +57 -1
  6. {python_libei-0.6.1 → python_libei-0.7.0/src/python_libei.egg-info}/PKG-INFO +3 -2
  7. {python_libei-0.6.1 → python_libei-0.7.0}/tests/test_abi.py +13 -2
  8. {python_libei-0.6.1 → python_libei-0.7.0}/tests/test_inputcapture.py +97 -0
  9. {python_libei-0.6.1 → python_libei-0.7.0}/LICENSE +0 -0
  10. {python_libei-0.6.1 → python_libei-0.7.0}/setup.cfg +0 -0
  11. {python_libei-0.6.1 → python_libei-0.7.0}/src/libei/_capi/__init__.py +0 -0
  12. {python_libei-0.6.1 → python_libei-0.7.0}/src/libei/_capi/libei.py +0 -0
  13. {python_libei-0.6.1 → python_libei-0.7.0}/src/libei/_capi/libeis.py +0 -0
  14. {python_libei-0.6.1 → python_libei-0.7.0}/src/libei/_capi/liboeffis.py +0 -0
  15. {python_libei-0.6.1 → python_libei-0.7.0}/src/libei/_capi/loader.py +0 -0
  16. {python_libei-0.6.1 → python_libei-0.7.0}/src/libei/_cobject.py +0 -0
  17. {python_libei-0.6.1 → python_libei-0.7.0}/src/libei/ei.py +0 -0
  18. {python_libei-0.6.1 → python_libei-0.7.0}/src/libei/eis.py +0 -0
  19. {python_libei-0.6.1 → python_libei-0.7.0}/src/libei/oeffis.py +0 -0
  20. {python_libei-0.6.1 → python_libei-0.7.0}/src/libei/py.typed +0 -0
  21. {python_libei-0.6.1 → python_libei-0.7.0}/src/python_libei.egg-info/SOURCES.txt +0 -0
  22. {python_libei-0.6.1 → python_libei-0.7.0}/src/python_libei.egg-info/dependency_links.txt +0 -0
  23. {python_libei-0.6.1 → python_libei-0.7.0}/src/python_libei.egg-info/requires.txt +0 -0
  24. {python_libei-0.6.1 → python_libei-0.7.0}/src/python_libei.egg-info/top_level.txt +0 -0
  25. {python_libei-0.6.1 → python_libei-0.7.0}/tests/test_cobject.py +0 -0
  26. {python_libei-0.6.1 → python_libei-0.7.0}/tests/test_documentation_shape.py +0 -0
  27. {python_libei-0.6.1 → python_libei-0.7.0}/tests/test_documented_examples.py +0 -0
  28. {python_libei-0.6.1 → python_libei-0.7.0}/tests/test_ei_objects.py +0 -0
  29. {python_libei-0.6.1 → python_libei-0.7.0}/tests/test_eis_objects.py +0 -0
  30. {python_libei-0.6.1 → python_libei-0.7.0}/tests/test_event_accessors.py +0 -0
  31. {python_libei-0.6.1 → python_libei-0.7.0}/tests/test_integration_extras.py +0 -0
  32. {python_libei-0.6.1 → python_libei-0.7.0}/tests/test_integration_frame.py +0 -0
  33. {python_libei-0.6.1 → python_libei-0.7.0}/tests/test_integration_lifetime.py +0 -0
  34. {python_libei-0.6.1 → python_libei-0.7.0}/tests/test_integration_socketpair.py +0 -0
  35. {python_libei-0.6.1 → python_libei-0.7.0}/tests/test_loader.py +0 -0
  36. {python_libei-0.6.1 → python_libei-0.7.0}/tests/test_oeffis.py +0 -0
  37. {python_libei-0.6.1 → python_libei-0.7.0}/tests/test_portal.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-libei
3
- Version: 0.6.1
3
+ Version: 0.7.0
4
4
  Summary: Inject, receive and capture input on Wayland from Python: ctypes bindings for libei, libeis and liboeffis, plus the RemoteDesktop and InputCapture portals
5
5
  Author: Dennis K. Paulsen
6
6
  License-Expression: MIT
@@ -40,6 +40,7 @@ Dynamic: license-file
40
40
 
41
41
  # python-libei
42
42
 
43
+ [![Contributing](https://img.shields.io/badge/contributions-welcome-brightgreen.svg)](https://github.com/ctrondlp/python-libei/blob/main/CONTRIBUTING.md)
43
44
  [![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)
44
45
  [![PyPI](https://img.shields.io/pypi/v/python-libei)](https://pypi.org/project/python-libei/)
45
46
  [![License](https://img.shields.io/pypi/l/python-libei)](https://github.com/ctrondlp/python-libei/blob/main/LICENSE)
@@ -174,7 +175,7 @@ and why, is in
174
175
 
175
176
  ## Status
176
177
 
177
- Beta (`0.6.1`), published on [PyPI](https://pypi.org/project/python-libei/)
178
+ Beta (`0.7.0`), published on [PyPI](https://pypi.org/project/python-libei/)
178
179
  since `0.1.0`, and **the API is not frozen** — expect renames before 1.0.
179
180
 
180
181
  The injection path is exercised end to end against the real libraries by the
@@ -1,5 +1,6 @@
1
1
  # python-libei
2
2
 
3
+ [![Contributing](https://img.shields.io/badge/contributions-welcome-brightgreen.svg)](https://github.com/ctrondlp/python-libei/blob/main/CONTRIBUTING.md)
3
4
  [![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
5
  [![PyPI](https://img.shields.io/pypi/v/python-libei)](https://pypi.org/project/python-libei/)
5
6
  [![License](https://img.shields.io/pypi/l/python-libei)](https://github.com/ctrondlp/python-libei/blob/main/LICENSE)
@@ -134,7 +135,7 @@ and why, is in
134
135
 
135
136
  ## Status
136
137
 
137
- Beta (`0.6.1`), published on [PyPI](https://pypi.org/project/python-libei/)
138
+ Beta (`0.7.0`), published on [PyPI](https://pypi.org/project/python-libei/)
138
139
  since `0.1.0`, and **the API is not frozen** — expect renames before 1.0.
139
140
 
140
141
  The injection path is exercised end to end against the real libraries by the
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-libei"
7
- version = "0.6.1"
7
+ version = "0.7.0"
8
8
  description = "Inject, receive and capture input on Wayland from Python: ctypes bindings for libei, libeis and liboeffis, plus the RemoteDesktop and InputCapture portals"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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.6.1"
34
+ __version__ = "0.7.0"
35
35
 
36
36
  __all__ = ["__version__"]
@@ -185,6 +185,28 @@ class PortalDeniedError(PortalError):
185
185
  self.message = message
186
186
 
187
187
 
188
+ class PortalSessionClosedError(PortalError):
189
+ """The portal session ended while something was still waiting on it.
190
+
191
+ The ``org.freedesktop.portal.Session`` interface emits ``Closed`` when
192
+ the compositor or the portal ends a session from its side -- a
193
+ compositor restart, the portal process dying, a session revoked from a
194
+ settings panel. Distinct from :class:`PortalTimeoutError` (the session
195
+ is fine, the signal just has not come) and from
196
+ :class:`PortalDeniedError` (a request was answered, negatively): here
197
+ there is no session left to answer anything, so the wait that raised
198
+ this can never be satisfied by waiting longer.
199
+ """
200
+
201
+ def __init__(self, step: str) -> None:
202
+ """Name what was being waited for when the session went away."""
203
+ super().__init__(
204
+ f"the portal session was closed while waiting for {step}; "
205
+ "the compositor or portal ended it from its side"
206
+ )
207
+ self.step = step
208
+
209
+
188
210
  def _gio() -> tuple[Any, Any] | None:
189
211
  """Import Gio and GLib, or return None.
190
212
 
@@ -1038,6 +1060,27 @@ def _wait_for_signal(
1038
1060
  loop = GLib.MainLoop()
1039
1061
  result: dict[str, Any] = {}
1040
1062
  timed_out = False
1063
+ session_closed = False
1064
+
1065
+ def on_closed(*_args: Any) -> None:
1066
+ """End the wait: a closed session can never deliver the signal.
1067
+
1068
+ ``Closed`` is emitted on the **session object's own path** (unlike
1069
+ ``Activated``/``Deactivated``, which fire on the portal object --
1070
+ see above), so the subscription below uses ``session_handle`` as
1071
+ the path and needs no payload filtering: any ``Closed`` there is
1072
+ this session's. Without this subscription, a wait with
1073
+ ``timeout=None`` on a session the compositor has ended -- a
1074
+ compositor restart, the portal dying, capture revoked from a
1075
+ settings panel -- blocked forever, indistinguishable from a signal
1076
+ that merely had not fired yet.
1077
+ """
1078
+ nonlocal session_closed
1079
+ if result:
1080
+ return
1081
+ logger.debug("session %s closed while waiting for %s", session_handle, signal)
1082
+ session_closed = True
1083
+ loop.quit()
1041
1084
 
1042
1085
  def on_signal(
1043
1086
  _conn: Any,
@@ -1086,6 +1129,16 @@ def _wait_for_signal(
1086
1129
  on_signal,
1087
1130
  None,
1088
1131
  )
1132
+ closed_subscription = connection.signal_subscribe(
1133
+ busname,
1134
+ _SESSION_INTERFACE,
1135
+ "Closed",
1136
+ session_handle,
1137
+ None,
1138
+ Gio.DBusSignalFlags.NONE,
1139
+ on_closed,
1140
+ None,
1141
+ )
1089
1142
  logger.debug(
1090
1143
  "waiting up to %s for %s.%s on %s for session %s",
1091
1144
  "forever" if timeout is None else f"{timeout:g}s",
@@ -1095,7 +1148,7 @@ def _wait_for_signal(
1095
1148
  session_handle,
1096
1149
  )
1097
1150
  try:
1098
- if not result:
1151
+ if not result and not session_closed:
1099
1152
  timeout_source = None
1100
1153
  if timeout is not None:
1101
1154
  timeout_source = GLib.timeout_add(int(timeout * 1000), on_timeout)
@@ -1115,6 +1168,9 @@ def _wait_for_signal(
1115
1168
  GLib.source_remove(timeout_source)
1116
1169
  finally:
1117
1170
  connection.signal_unsubscribe(subscription)
1171
+ connection.signal_unsubscribe(closed_subscription)
1172
+ if session_closed:
1173
+ raise PortalSessionClosedError(signal)
1118
1174
  if timed_out:
1119
1175
  # timed_out is only ever set inside on_timeout, itself only ever
1120
1176
  # registered when timeout is not None -- so this always holds, but
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-libei
3
- Version: 0.6.1
3
+ Version: 0.7.0
4
4
  Summary: Inject, receive and capture input on Wayland from Python: ctypes bindings for libei, libeis and liboeffis, plus the RemoteDesktop and InputCapture portals
5
5
  Author: Dennis K. Paulsen
6
6
  License-Expression: MIT
@@ -40,6 +40,7 @@ Dynamic: license-file
40
40
 
41
41
  # python-libei
42
42
 
43
+ [![Contributing](https://img.shields.io/badge/contributions-welcome-brightgreen.svg)](https://github.com/ctrondlp/python-libei/blob/main/CONTRIBUTING.md)
43
44
  [![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)
44
45
  [![PyPI](https://img.shields.io/pypi/v/python-libei)](https://pypi.org/project/python-libei/)
45
46
  [![License](https://img.shields.io/pypi/l/python-libei)](https://github.com/ctrondlp/python-libei/blob/main/LICENSE)
@@ -174,7 +175,7 @@ and why, is in
174
175
 
175
176
  ## Status
176
177
 
177
- Beta (`0.6.1`), published on [PyPI](https://pypi.org/project/python-libei/)
178
+ Beta (`0.7.0`), published on [PyPI](https://pypi.org/project/python-libei/)
178
179
  since `0.1.0`, and **the API is not frozen** — expect renames before 1.0.
179
180
 
180
181
  The injection path is exercised end to end against the real libraries by the
@@ -97,7 +97,15 @@ def _declared(prefix: str) -> dict[str, tuple[tuple[type, ...], type | None]]:
97
97
 
98
98
 
99
99
  def _installed_version(soname: str) -> tuple[int, ...] | None:
100
- """The version in the loaded library's file name (``libei.so.1.6.0``)."""
100
+ """The version in the loaded library's file name (``libei.so.1.6.0``).
101
+
102
+ A micro of 900 or more is upstream's prerelease numbering for the *next*
103
+ minor -- Debian 13 ships 1.3.901, a snapshot on the way to 1.4 that
104
+ already exports the 1.4 symbols -- so it is normalized to that next
105
+ minor. Read literally, 1.3.901 "predates" every 1.4 marker while
106
+ carrying everything they mark, which failed the absent-function pin
107
+ below on a library whose only sin was being packaged mid-cycle.
108
+ """
101
109
  try:
102
110
  maps = Path("/proc/self/maps").read_text()
103
111
  except OSError:
@@ -106,7 +114,10 @@ def _installed_version(soname: str) -> tuple[int, ...] | None:
106
114
  for line in maps.splitlines():
107
115
  found = re.search(rf"/{re.escape(stem)}\.so\.(\d+\.\d+\.\d+)$", line)
108
116
  if found:
109
- return _version(found.group(1))
117
+ major, minor, micro = _version(found.group(1))
118
+ if micro >= 900:
119
+ return (major, minor + 1, 0)
120
+ return (major, minor, micro)
110
121
  return None
111
122
 
112
123
 
@@ -83,6 +83,7 @@ class FakeInputCaptureConnection:
83
83
  pending_activated: dict[str, Any] | None = None,
84
84
  pending_deactivated: dict[str, Any] | None = None,
85
85
  signal_session_handle: str | None = None,
86
+ pending_closed: bool = False,
86
87
  ) -> None:
87
88
  self.session_handle = session_handle
88
89
  self.signal_session_handle = signal_session_handle or session_handle
@@ -105,6 +106,7 @@ class FakeInputCaptureConnection:
105
106
  self._pending_reply: tuple[str, int, dict[str, Any]] | None = None
106
107
  self._pending_activated = pending_activated
107
108
  self._pending_deactivated = pending_deactivated
109
+ self._pending_closed = pending_closed
108
110
  # A permanent log, unlike _subscriptions: signal_unsubscribe removes
109
111
  # an entry from that (correctly -- see its own docstring), so a test
110
112
  # checking *what was subscribed to* after the fact needs a record
@@ -184,6 +186,13 @@ class FakeInputCaptureConnection:
184
186
  payload = self._pending_deactivated
185
187
  self._pending_deactivated = None
186
188
  self._emit_session_signal(callback, "Deactivated", payload)
189
+ elif signal == "Closed" and self._pending_closed:
190
+ # Closed fires on the session object's own path, not the
191
+ # portal's -- the opposite of Activated/Deactivated above -- so
192
+ # it is only delivered to a subscription made there.
193
+ if path == self.session_handle:
194
+ self._pending_closed = False
195
+ callback(None, None, path, None, "Closed", FakeReply(({},)))
187
196
  return self._next_subscription_id
188
197
 
189
198
  def _emit_session_signal(
@@ -770,3 +779,91 @@ def test_a_failed_session_close_does_not_propagate() -> None:
770
779
  with install_fake_gi(connection):
771
780
  session = portal.InputCaptureSession.negotiate(connection=connection)
772
781
  session.close() # must not raise
782
+
783
+
784
+ # -- the session Closed signal ------------------------------------------------
785
+
786
+
787
+ def test_a_closed_session_ends_wait_for_activation_rather_than_hanging() -> None:
788
+ # The compositor-disconnect case: Session.Closed fires when the portal
789
+ # or compositor ends the session from its side. Before the Closed
790
+ # subscription existed, a wait with timeout=None on such a session
791
+ # blocked forever -- indistinguishable from a barrier nobody had
792
+ # crossed yet.
793
+ connection = FakeInputCaptureConnection(pending_closed=True)
794
+ with install_fake_gi(connection):
795
+ session = portal.InputCaptureSession.negotiate(connection=connection)
796
+ with pytest.raises(portal.PortalSessionClosedError) as excinfo:
797
+ session.wait_for_activation(timeout=None)
798
+ assert excinfo.value.step == "Activated"
799
+
800
+
801
+ def test_a_closed_session_ends_wait_for_deactivation_too() -> None:
802
+ connection = FakeInputCaptureConnection(pending_closed=True)
803
+ with install_fake_gi(connection):
804
+ session = portal.InputCaptureSession.negotiate(connection=connection)
805
+ with pytest.raises(portal.PortalSessionClosedError) as excinfo:
806
+ session.wait_for_deactivation(timeout=None)
807
+ assert excinfo.value.step == "Deactivated"
808
+
809
+
810
+ def test_closed_is_subscribed_on_the_session_path_not_the_portal() -> None:
811
+ # The mirror image of the Activated-path bug: Closed is the one signal
812
+ # that really does fire on the session object's own path, so the
813
+ # subscription has to go there.
814
+ connection = FakeInputCaptureConnection()
815
+ with install_fake_gi(connection):
816
+ session = portal.InputCaptureSession.negotiate(connection=connection)
817
+ with pytest.raises(portal.PortalTimeoutError):
818
+ session.wait_for_activation(timeout=0.01)
819
+ subscribed = {
820
+ key[2] for key in connection.signal_subscriptions if key[1] == "Closed"
821
+ }
822
+ assert subscribed == {connection.session_handle}
823
+
824
+
825
+ def test_every_wait_subscription_is_unsubscribed_again() -> None:
826
+ # Both the signal's own subscription and the Closed one: a leak here
827
+ # means a long-running capture loop accumulates one dead subscription
828
+ # per activation cycle.
829
+ connection = FakeInputCaptureConnection(pending_activated={"activation_id": 1})
830
+ with install_fake_gi(connection):
831
+ session = portal.InputCaptureSession.negotiate(connection=connection)
832
+ session.wait_for_activation(timeout=1.0)
833
+ live = [k for k in connection._subscriptions if k[1] in ("Activated", "Closed")]
834
+ assert live == []
835
+
836
+
837
+ def test_a_session_closed_mid_wait_still_cleans_up_its_subscriptions() -> None:
838
+ connection = FakeInputCaptureConnection(pending_closed=True)
839
+ with install_fake_gi(connection):
840
+ session = portal.InputCaptureSession.negotiate(connection=connection)
841
+ with pytest.raises(portal.PortalSessionClosedError):
842
+ session.wait_for_activation(timeout=None)
843
+ live = [k for k in connection._subscriptions if k[1] in ("Activated", "Closed")]
844
+ assert live == []
845
+
846
+
847
+ def test_repeated_activation_cycles_each_get_their_own_signal() -> None:
848
+ # A capture loop waits, releases, waits again -- each wait is its own
849
+ # subscription, delivered its own payload, with nothing carried over.
850
+ connection = FakeInputCaptureConnection(pending_activated={"activation_id": 1})
851
+ with install_fake_gi(connection):
852
+ session = portal.InputCaptureSession.negotiate(connection=connection)
853
+ first = session.wait_for_activation(timeout=1.0)
854
+ connection._pending_activated = {"activation_id": 2}
855
+ second = session.wait_for_activation(timeout=1.0)
856
+ assert (first.activation_id, second.activation_id) == (1, 2)
857
+
858
+
859
+ def test_an_activation_payload_without_an_id_defaults_to_zero() -> None:
860
+ # The spec says activation_id is always sent; a portal that omits it
861
+ # anyway gets the same 0 a compositor that "could not determine" sends,
862
+ # rather than a KeyError mid-wait.
863
+ connection = FakeInputCaptureConnection(pending_activated={})
864
+ with install_fake_gi(connection):
865
+ session = portal.InputCaptureSession.negotiate(connection=connection)
866
+ activation = session.wait_for_activation(timeout=1.0)
867
+ assert activation == portal.Activation(
868
+ activation_id=0, cursor_position=None, barrier_id=None
869
+ )
File without changes
File without changes