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.
- {python_libei-0.6.1/src/python_libei.egg-info → python_libei-0.7.0}/PKG-INFO +3 -2
- {python_libei-0.6.1 → python_libei-0.7.0}/README.md +2 -1
- {python_libei-0.6.1 → python_libei-0.7.0}/pyproject.toml +1 -1
- {python_libei-0.6.1 → python_libei-0.7.0}/src/libei/__init__.py +1 -1
- {python_libei-0.6.1 → python_libei-0.7.0}/src/libei/portal.py +57 -1
- {python_libei-0.6.1 → python_libei-0.7.0/src/python_libei.egg-info}/PKG-INFO +3 -2
- {python_libei-0.6.1 → python_libei-0.7.0}/tests/test_abi.py +13 -2
- {python_libei-0.6.1 → python_libei-0.7.0}/tests/test_inputcapture.py +97 -0
- {python_libei-0.6.1 → python_libei-0.7.0}/LICENSE +0 -0
- {python_libei-0.6.1 → python_libei-0.7.0}/setup.cfg +0 -0
- {python_libei-0.6.1 → python_libei-0.7.0}/src/libei/_capi/__init__.py +0 -0
- {python_libei-0.6.1 → python_libei-0.7.0}/src/libei/_capi/libei.py +0 -0
- {python_libei-0.6.1 → python_libei-0.7.0}/src/libei/_capi/libeis.py +0 -0
- {python_libei-0.6.1 → python_libei-0.7.0}/src/libei/_capi/liboeffis.py +0 -0
- {python_libei-0.6.1 → python_libei-0.7.0}/src/libei/_capi/loader.py +0 -0
- {python_libei-0.6.1 → python_libei-0.7.0}/src/libei/_cobject.py +0 -0
- {python_libei-0.6.1 → python_libei-0.7.0}/src/libei/ei.py +0 -0
- {python_libei-0.6.1 → python_libei-0.7.0}/src/libei/eis.py +0 -0
- {python_libei-0.6.1 → python_libei-0.7.0}/src/libei/oeffis.py +0 -0
- {python_libei-0.6.1 → python_libei-0.7.0}/src/libei/py.typed +0 -0
- {python_libei-0.6.1 → python_libei-0.7.0}/src/python_libei.egg-info/SOURCES.txt +0 -0
- {python_libei-0.6.1 → python_libei-0.7.0}/src/python_libei.egg-info/dependency_links.txt +0 -0
- {python_libei-0.6.1 → python_libei-0.7.0}/src/python_libei.egg-info/requires.txt +0 -0
- {python_libei-0.6.1 → python_libei-0.7.0}/src/python_libei.egg-info/top_level.txt +0 -0
- {python_libei-0.6.1 → python_libei-0.7.0}/tests/test_cobject.py +0 -0
- {python_libei-0.6.1 → python_libei-0.7.0}/tests/test_documentation_shape.py +0 -0
- {python_libei-0.6.1 → python_libei-0.7.0}/tests/test_documented_examples.py +0 -0
- {python_libei-0.6.1 → python_libei-0.7.0}/tests/test_ei_objects.py +0 -0
- {python_libei-0.6.1 → python_libei-0.7.0}/tests/test_eis_objects.py +0 -0
- {python_libei-0.6.1 → python_libei-0.7.0}/tests/test_event_accessors.py +0 -0
- {python_libei-0.6.1 → python_libei-0.7.0}/tests/test_integration_extras.py +0 -0
- {python_libei-0.6.1 → python_libei-0.7.0}/tests/test_integration_frame.py +0 -0
- {python_libei-0.6.1 → python_libei-0.7.0}/tests/test_integration_lifetime.py +0 -0
- {python_libei-0.6.1 → python_libei-0.7.0}/tests/test_integration_socketpair.py +0 -0
- {python_libei-0.6.1 → python_libei-0.7.0}/tests/test_loader.py +0 -0
- {python_libei-0.6.1 → python_libei-0.7.0}/tests/test_oeffis.py +0 -0
- {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.
|
|
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
|
+
[](https://github.com/ctrondlp/python-libei/blob/main/CONTRIBUTING.md)
|
|
43
44
|
[](https://github.com/ctrondlp/python-libei/actions/workflows/ci.yml)
|
|
44
45
|
[](https://pypi.org/project/python-libei/)
|
|
45
46
|
[](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.
|
|
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
|
+
[](https://github.com/ctrondlp/python-libei/blob/main/CONTRIBUTING.md)
|
|
3
4
|
[](https://github.com/ctrondlp/python-libei/actions/workflows/ci.yml)
|
|
4
5
|
[](https://pypi.org/project/python-libei/)
|
|
5
6
|
[](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.
|
|
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.
|
|
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"
|
|
@@ -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.
|
|
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
|
+
[](https://github.com/ctrondlp/python-libei/blob/main/CONTRIBUTING.md)
|
|
43
44
|
[](https://github.com/ctrondlp/python-libei/actions/workflows/ci.yml)
|
|
44
45
|
[](https://pypi.org/project/python-libei/)
|
|
45
46
|
[](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.
|
|
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
|
-
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|