python-mpv-jsonipc 1.4.0__tar.gz → 1.5.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_mpv_jsonipc-1.4.0/python_mpv_jsonipc.egg-info → python_mpv_jsonipc-1.5.0}/PKG-INFO +5 -1
- {python_mpv_jsonipc-1.4.0 → python_mpv_jsonipc-1.5.0}/README.md +4 -0
- {python_mpv_jsonipc-1.4.0 → python_mpv_jsonipc-1.5.0}/pyproject.toml +1 -1
- {python_mpv_jsonipc-1.4.0 → python_mpv_jsonipc-1.5.0/python_mpv_jsonipc.egg-info}/PKG-INFO +5 -1
- {python_mpv_jsonipc-1.4.0 → python_mpv_jsonipc-1.5.0}/python_mpv_jsonipc.egg-info/SOURCES.txt +1 -0
- {python_mpv_jsonipc-1.4.0 → python_mpv_jsonipc-1.5.0}/python_mpv_jsonipc.py +254 -27
- {python_mpv_jsonipc-1.4.0 → python_mpv_jsonipc-1.5.0}/tests/test_arg_building.py +4 -2
- python_mpv_jsonipc-1.5.0/tests/test_live_async.py +550 -0
- {python_mpv_jsonipc-1.4.0 → python_mpv_jsonipc-1.5.0}/tests/test_live_teardown.py +26 -2
- {python_mpv_jsonipc-1.4.0 → python_mpv_jsonipc-1.5.0}/LICENSE.md +0 -0
- {python_mpv_jsonipc-1.4.0 → python_mpv_jsonipc-1.5.0}/python_mpv_jsonipc.egg-info/dependency_links.txt +0 -0
- {python_mpv_jsonipc-1.4.0 → python_mpv_jsonipc-1.5.0}/python_mpv_jsonipc.egg-info/top_level.txt +0 -0
- {python_mpv_jsonipc-1.4.0 → python_mpv_jsonipc-1.5.0}/setup.cfg +0 -0
- {python_mpv_jsonipc-1.4.0 → python_mpv_jsonipc-1.5.0}/tests/test_api_surface.py +0 -0
- {python_mpv_jsonipc-1.4.0 → python_mpv_jsonipc-1.5.0}/tests/test_live_basic.py +0 -0
- {python_mpv_jsonipc-1.4.0 → python_mpv_jsonipc-1.5.0}/tests/test_live_events.py +0 -0
- {python_mpv_jsonipc-1.4.0 → python_mpv_jsonipc-1.5.0}/tests/test_live_failure.py +0 -0
- {python_mpv_jsonipc-1.4.0 → python_mpv_jsonipc-1.5.0}/tests/test_python_floor.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-mpv-jsonipc
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.5.0
|
|
4
4
|
Summary: Python API to MPV using JSON IPC
|
|
5
5
|
Author-email: Izzie Walton <izzie@iwalton.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -55,6 +55,10 @@ mpv.volume = 20
|
|
|
55
55
|
# You can also send commands.
|
|
56
56
|
mpv.command_name(arg1, arg2)
|
|
57
57
|
|
|
58
|
+
# Or send one without waiting for it. Returns a concurrent.futures.Future.
|
|
59
|
+
mpv.command_async("sub-add", "https://example.com/slow.srt",
|
|
60
|
+
callback=lambda error, result: print(error or "loaded"))
|
|
61
|
+
|
|
58
62
|
# Bind to key press events with a decorator
|
|
59
63
|
@mpv.on_key_press("space")
|
|
60
64
|
def space_handler():
|
|
@@ -41,6 +41,10 @@ mpv.volume = 20
|
|
|
41
41
|
# You can also send commands.
|
|
42
42
|
mpv.command_name(arg1, arg2)
|
|
43
43
|
|
|
44
|
+
# Or send one without waiting for it. Returns a concurrent.futures.Future.
|
|
45
|
+
mpv.command_async("sub-add", "https://example.com/slow.srt",
|
|
46
|
+
callback=lambda error, result: print(error or "loaded"))
|
|
47
|
+
|
|
44
48
|
# Bind to key press events with a decorator
|
|
45
49
|
@mpv.on_key_press("space")
|
|
46
50
|
def space_handler():
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-mpv-jsonipc
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.5.0
|
|
4
4
|
Summary: Python API to MPV using JSON IPC
|
|
5
5
|
Author-email: Izzie Walton <izzie@iwalton.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -55,6 +55,10 @@ mpv.volume = 20
|
|
|
55
55
|
# You can also send commands.
|
|
56
56
|
mpv.command_name(arg1, arg2)
|
|
57
57
|
|
|
58
|
+
# Or send one without waiting for it. Returns a concurrent.futures.Future.
|
|
59
|
+
mpv.command_async("sub-add", "https://example.com/slow.srt",
|
|
60
|
+
callback=lambda error, result: print(error or "loaded"))
|
|
61
|
+
|
|
58
62
|
# Bind to key press events with a decorator
|
|
59
63
|
@mpv.on_key_press("space")
|
|
60
64
|
def space_handler():
|
|
@@ -8,6 +8,7 @@ import subprocess
|
|
|
8
8
|
import random
|
|
9
9
|
import queue
|
|
10
10
|
import logging
|
|
11
|
+
from concurrent.futures import Future
|
|
11
12
|
|
|
12
13
|
log = logging.getLogger('mpv-jsonipc')
|
|
13
14
|
|
|
@@ -420,8 +421,13 @@ class WindowsSocket(threading.Thread):
|
|
|
420
421
|
# finally below, only once its own read has returned.
|
|
421
422
|
nread = self._transfer(_kernel32.ReadFile, self._read_event,
|
|
422
423
|
buf, len(buf))
|
|
423
|
-
if
|
|
424
|
+
if nread is None: # the peer is gone, or stop()
|
|
424
425
|
break
|
|
426
|
+
if nread == 0:
|
|
427
|
+
# A zero-length message, not the end: mpv's pipe is
|
|
428
|
+
# message-mode and it answers an async command with an
|
|
429
|
+
# empty write. Treating it as EOF closed the connection.
|
|
430
|
+
continue
|
|
425
431
|
|
|
426
432
|
data += buf.raw[:nread]
|
|
427
433
|
if data[-1] != 10:
|
|
@@ -699,6 +705,27 @@ class MPVProcess:
|
|
|
699
705
|
except FileNotFoundError:
|
|
700
706
|
pass # The other thread got there first, which is all we wanted.
|
|
701
707
|
|
|
708
|
+
#: Stands in a sync command's result slot when the connection closed first.
|
|
709
|
+
_CLOSED = object()
|
|
710
|
+
|
|
711
|
+
def _call_user(what, func, *args):
|
|
712
|
+
"""Call user code from a thread this library owns, logging anything it
|
|
713
|
+
raises -- SystemExit included: a caller's bug must not end the reader,
|
|
714
|
+
the close sweep or the drain. Not for EventHandler.run, which a
|
|
715
|
+
BaseException still ends (see there)."""
|
|
716
|
+
try:
|
|
717
|
+
func(*args)
|
|
718
|
+
except BaseException:
|
|
719
|
+
log.error("%s raised.", what, exc_info=1)
|
|
720
|
+
|
|
721
|
+
def _reply_outcome(data):
|
|
722
|
+
"""``(error, result)`` for one IPC reply."""
|
|
723
|
+
if data["error"] == "success":
|
|
724
|
+
return None, data.get("data")
|
|
725
|
+
if data["error"] == "property unavailable":
|
|
726
|
+
return None, None
|
|
727
|
+
return MPVError(data["error"]), None
|
|
728
|
+
|
|
702
729
|
class MPVInter:
|
|
703
730
|
"""
|
|
704
731
|
Low-level interface to MPV. Does NOT manage an mpv process. (Internal)
|
|
@@ -708,6 +735,7 @@ class MPVInter:
|
|
|
708
735
|
|
|
709
736
|
*ipc_socket* is the path to the Unix/Linux socket or name of the Windows pipe.
|
|
710
737
|
*callback(event_name, data)* is the function for recieving events.
|
|
738
|
+
It runs on the reader thread; an exception from it is logged.
|
|
711
739
|
*quit_callback* is called when the socket connection to MPV dies.
|
|
712
740
|
"""
|
|
713
741
|
Socket = UnixSocket
|
|
@@ -719,25 +747,64 @@ class MPVInter:
|
|
|
719
747
|
if self.callback is None:
|
|
720
748
|
self.callback = lambda event, data: None
|
|
721
749
|
|
|
722
|
-
self.socket = Socket(ipc_socket, self.event_callback, self.quit_callback)
|
|
723
|
-
self.socket.start()
|
|
724
750
|
self.command_id = 1
|
|
725
751
|
self.rid_lock = threading.Lock()
|
|
726
752
|
self.socket_lock = threading.Lock()
|
|
727
753
|
self.cid_result = {}
|
|
728
754
|
self.cid_wait = {}
|
|
755
|
+
# Before the reader starts: it can deliver a reply, or die, at once.
|
|
756
|
+
self.cid_async = {}
|
|
757
|
+
self.async_lock = threading.Lock()
|
|
758
|
+
self.closed = False
|
|
759
|
+
|
|
760
|
+
self.socket = Socket(ipc_socket, self.event_callback, self._on_quit)
|
|
761
|
+
self.socket.start()
|
|
729
762
|
|
|
730
763
|
def stop(self, join=True):
|
|
731
764
|
"""Terminate the underlying connection."""
|
|
732
765
|
self.socket.stop(join)
|
|
766
|
+
# The reader's _on_quit usually gets here first. This one does not
|
|
767
|
+
# depend on it, so nothing is left pending once stop() returns.
|
|
768
|
+
self._fail_async()
|
|
769
|
+
|
|
770
|
+
def _on_quit(self):
|
|
771
|
+
self._fail_async()
|
|
772
|
+
if self.quit_callback:
|
|
773
|
+
self.quit_callback()
|
|
774
|
+
|
|
775
|
+
def _fail_async(self):
|
|
776
|
+
"""Answer every pending command, async or not: nothing else ever will."""
|
|
777
|
+
with self.async_lock:
|
|
778
|
+
self.closed = True
|
|
779
|
+
pending, self.cid_async = self.cid_async, {}
|
|
780
|
+
for command_id, event in list(self.cid_wait.items()):
|
|
781
|
+
# setdefault: a reply that landed just before still wins.
|
|
782
|
+
self.cid_result.setdefault(command_id, _CLOSED)
|
|
783
|
+
event.set()
|
|
784
|
+
for on_reply in pending.values():
|
|
785
|
+
_call_user("command_async reply handler", on_reply,
|
|
786
|
+
BrokenPipeError("MPV connection closed."), None)
|
|
733
787
|
|
|
734
788
|
def event_callback(self, data):
|
|
735
789
|
"""Internal callback for recieving events from MPV."""
|
|
736
790
|
if "request_id" in data:
|
|
737
|
-
|
|
738
|
-
self.
|
|
791
|
+
request_id = data["request_id"]
|
|
792
|
+
with self.async_lock:
|
|
793
|
+
on_reply = self.cid_async.pop(request_id, None)
|
|
794
|
+
if on_reply is not None:
|
|
795
|
+
_call_user("command_async reply handler", on_reply,
|
|
796
|
+
*_reply_outcome(data))
|
|
797
|
+
return
|
|
798
|
+
# Not indexed: a reply nobody waits for (mpv answers id 0 to a
|
|
799
|
+
# request without one) would raise here, on the reader thread,
|
|
800
|
+
# and end the read loop as if the connection had died.
|
|
801
|
+
event = self.cid_wait.get(request_id)
|
|
802
|
+
if event is not None:
|
|
803
|
+
self.cid_result[request_id] = data
|
|
804
|
+
event.set()
|
|
739
805
|
elif "event" in data:
|
|
740
|
-
|
|
806
|
+
_call_user("MPVInter event callback", self.callback,
|
|
807
|
+
data["event"], data)
|
|
741
808
|
|
|
742
809
|
def command(self, command, *args):
|
|
743
810
|
"""
|
|
@@ -746,7 +813,8 @@ class MPVInter:
|
|
|
746
813
|
*command* is the name of the MPV command
|
|
747
814
|
|
|
748
815
|
All further arguments are forwarded to the MPV command.
|
|
749
|
-
Throws TimeoutError if timeout of 120 seconds is reached
|
|
816
|
+
Throws TimeoutError if timeout of 120 seconds is reached, and
|
|
817
|
+
BrokenPipeError as soon as the connection closes before the reply.
|
|
750
818
|
"""
|
|
751
819
|
self.rid_lock.acquire()
|
|
752
820
|
command_id = self.command_id
|
|
@@ -754,7 +822,12 @@ class MPVInter:
|
|
|
754
822
|
self.rid_lock.release()
|
|
755
823
|
|
|
756
824
|
event = threading.Event()
|
|
757
|
-
|
|
825
|
+
# Under the lock _fail_async takes, so a close either finds this
|
|
826
|
+
# waiter or is seen here first; neither way waits out TIMEOUT.
|
|
827
|
+
with self.async_lock:
|
|
828
|
+
if self.closed:
|
|
829
|
+
raise BrokenPipeError("MPV connection closed.")
|
|
830
|
+
self.cid_wait[command_id] = event
|
|
758
831
|
|
|
759
832
|
command_list = [command]
|
|
760
833
|
command_list.extend(args)
|
|
@@ -766,23 +839,59 @@ class MPVInter:
|
|
|
766
839
|
|
|
767
840
|
has_event = event.wait(timeout=TIMEOUT)
|
|
768
841
|
if has_event:
|
|
769
|
-
data = self.cid_result
|
|
770
|
-
del self.cid_result[command_id]
|
|
842
|
+
data = self.cid_result.pop(command_id)
|
|
771
843
|
del self.cid_wait[command_id]
|
|
772
|
-
if data
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
844
|
+
if data is _CLOSED:
|
|
845
|
+
raise BrokenPipeError("MPV connection closed.")
|
|
846
|
+
error, result = _reply_outcome(data)
|
|
847
|
+
if error is not None:
|
|
848
|
+
raise error
|
|
849
|
+
return result
|
|
778
850
|
else:
|
|
779
851
|
raise TimeoutError("No response from MPV.")
|
|
780
852
|
|
|
853
|
+
def command_async(self, on_reply, command, *args):
|
|
854
|
+
"""
|
|
855
|
+
Issue a command to MPV without waiting for it to finish.
|
|
856
|
+
|
|
857
|
+
*on_reply(error, result)* is called exactly once: with MPV's answer on
|
|
858
|
+
the transport's reader thread; with BrokenPipeError on the thread
|
|
859
|
+
that closes the connection, or on the calling thread if it is already
|
|
860
|
+
closed; or with the send's own error on the calling thread. An
|
|
861
|
+
exception from it is logged when the reader or the close calls it,
|
|
862
|
+
and raised to the caller otherwise. There is no timeout.
|
|
863
|
+
"""
|
|
864
|
+
with self.rid_lock:
|
|
865
|
+
command_id = self.command_id
|
|
866
|
+
self.command_id += 1
|
|
867
|
+
|
|
868
|
+
with self.async_lock:
|
|
869
|
+
closed = self.closed
|
|
870
|
+
if not closed:
|
|
871
|
+
self.cid_async[command_id] = on_reply
|
|
872
|
+
if closed:
|
|
873
|
+
on_reply(BrokenPipeError("MPV connection closed."), None)
|
|
874
|
+
return
|
|
875
|
+
|
|
876
|
+
command_list = [command]
|
|
877
|
+
command_list.extend(args)
|
|
878
|
+
try:
|
|
879
|
+
with self.socket_lock:
|
|
880
|
+
self.socket.send({"command": command_list,
|
|
881
|
+
"request_id": command_id, "async": True})
|
|
882
|
+
except Exception as error:
|
|
883
|
+
with self.async_lock:
|
|
884
|
+
owned = self.cid_async.pop(command_id, None)
|
|
885
|
+
if owned is not None: # else _fail_async has already answered
|
|
886
|
+
on_reply(error, None)
|
|
887
|
+
|
|
781
888
|
class EventHandler(threading.Thread):
|
|
782
889
|
"""Event handling thread. (Internal)"""
|
|
783
890
|
def __init__(self):
|
|
784
891
|
"""Create an instance of the thread."""
|
|
785
892
|
self.queue = queue.Queue()
|
|
893
|
+
self.stopped = False
|
|
894
|
+
self.stop_lock = threading.Lock()
|
|
786
895
|
threading.Thread.__init__(self)
|
|
787
896
|
self.daemon = True
|
|
788
897
|
|
|
@@ -793,24 +902,62 @@ class EventHandler(threading.Thread):
|
|
|
793
902
|
*func* is the function to call
|
|
794
903
|
|
|
795
904
|
All further arguments are forwarded to *func*.
|
|
905
|
+
|
|
906
|
+
Returns False, without queueing it, once the thread has stopped --
|
|
907
|
+
by stop(), or because a task raised SystemExit or the like.
|
|
796
908
|
"""
|
|
797
|
-
self.
|
|
909
|
+
with self.stop_lock:
|
|
910
|
+
if self.stopped:
|
|
911
|
+
return False
|
|
912
|
+
self.queue.put((func, args))
|
|
913
|
+
return True
|
|
798
914
|
|
|
799
915
|
def stop(self, join=True):
|
|
800
916
|
"""Terminate the thread."""
|
|
801
|
-
self.
|
|
802
|
-
|
|
917
|
+
with self.stop_lock:
|
|
918
|
+
self.stopped = True
|
|
919
|
+
self.queue.put("quit")
|
|
920
|
+
# A callback that calls terminate() is on this thread. The guard is
|
|
921
|
+
# the transports' (see UnixSocket.stop); `join` stays the timeout.
|
|
922
|
+
if self.ident is not None and self is not threading.current_thread():
|
|
923
|
+
self.join(join)
|
|
803
924
|
|
|
804
925
|
def run(self):
|
|
805
926
|
"""Process socket events. Do not run this directly. Use *start*."""
|
|
927
|
+
try:
|
|
928
|
+
while True:
|
|
929
|
+
event = self.queue.get()
|
|
930
|
+
if event == "quit":
|
|
931
|
+
break
|
|
932
|
+
try:
|
|
933
|
+
event[0](*event[1])
|
|
934
|
+
except Exception:
|
|
935
|
+
log.error("EventHandler caught exception from %s.", event, exc_info=1)
|
|
936
|
+
except BaseException as ex:
|
|
937
|
+
log.warning("A callback raised %r, ending the event thread: "
|
|
938
|
+
"property observers, key bindings and events will no "
|
|
939
|
+
"longer be delivered; command_async callbacks now run "
|
|
940
|
+
"on whichever thread delivers their outcome.", ex)
|
|
941
|
+
# Not _call_user, deliberately: a callback's SystemExit ends this
|
|
942
|
+
# thread, as it has since 2020.
|
|
943
|
+
raise
|
|
944
|
+
finally:
|
|
945
|
+
self._drain()
|
|
946
|
+
|
|
947
|
+
def _drain(self):
|
|
948
|
+
"""A callback's SystemExit (or similar) ends this thread, as it always
|
|
949
|
+
has. Refuse work from here on, so put_task says so, and run what was
|
|
950
|
+
already queued: a command_async future may be waiting in it."""
|
|
951
|
+
with self.stop_lock:
|
|
952
|
+
self.stopped = True
|
|
806
953
|
while True:
|
|
807
|
-
event = self.queue.get()
|
|
808
|
-
if event == "quit":
|
|
809
|
-
break
|
|
810
954
|
try:
|
|
811
|
-
event
|
|
812
|
-
except
|
|
813
|
-
|
|
955
|
+
event = self.queue.get_nowait()
|
|
956
|
+
except queue.Empty:
|
|
957
|
+
return
|
|
958
|
+
if event == "quit":
|
|
959
|
+
continue
|
|
960
|
+
_call_user("EventHandler task", event[0], *event[1])
|
|
814
961
|
|
|
815
962
|
class MPV:
|
|
816
963
|
"""
|
|
@@ -833,7 +980,8 @@ class MPV:
|
|
|
833
980
|
*mpv_location* is the location of MPV for *start_mpv*. (Default: Use MPV in PATH)
|
|
834
981
|
*log_handler(level, prefix, text)* is an optional handler for log events. (Default: Disabled)
|
|
835
982
|
*loglevel* is the level for log messages. Levels are fatal, error, warn, info, v, debug, trace. (Default: Disabled)
|
|
836
|
-
*quit_callback* is called when the socket connection to MPV dies.
|
|
983
|
+
*quit_callback* is called on the reader thread when the socket connection to MPV dies.
|
|
984
|
+
An exception from it is logged, and MPV is still terminated.
|
|
837
985
|
*diagnose_start_failures* re-runs MPV once, on the first failed start, to find out
|
|
838
986
|
why it refused to start. If MPV names an option it does not have, MPVProcessError is
|
|
839
987
|
raised immediately with *bad_option* set instead of spending every retry on something
|
|
@@ -958,7 +1106,7 @@ class MPV:
|
|
|
958
1106
|
Internal handler for quit events.
|
|
959
1107
|
"""
|
|
960
1108
|
if self.quit_callback:
|
|
961
|
-
self.quit_callback
|
|
1109
|
+
_call_user("quit_callback", self.quit_callback)
|
|
962
1110
|
self.terminate(join=False)
|
|
963
1111
|
|
|
964
1112
|
def bind_event(self, name, callback):
|
|
@@ -1068,6 +1216,11 @@ class MPV:
|
|
|
1068
1216
|
Waits for the value of a property to change.
|
|
1069
1217
|
|
|
1070
1218
|
*name* is the name of the property.
|
|
1219
|
+
|
|
1220
|
+
Never call it from inside a callback here: the change is delivered on
|
|
1221
|
+
the event thread, which the wait would be blocking, so it never ends.
|
|
1222
|
+
It also never returns once a callback has ended the event thread,
|
|
1223
|
+
since the change is then never delivered.
|
|
1071
1224
|
"""
|
|
1072
1225
|
event = threading.Event()
|
|
1073
1226
|
first_event = True
|
|
@@ -1126,6 +1279,80 @@ class MPV:
|
|
|
1126
1279
|
"""
|
|
1127
1280
|
return self.mpv_inter.command(command, *args)
|
|
1128
1281
|
|
|
1282
|
+
def command_async(self, command, *args, callback=None):
|
|
1283
|
+
"""
|
|
1284
|
+
Send a command to MPV without waiting for it to finish, for one that
|
|
1285
|
+
can take a long time (``sub-add`` of a slow URL). Unlike *command*,
|
|
1286
|
+
it holds neither the caller nor the connection: other commands are
|
|
1287
|
+
answered while it runs, and it has no timeout.
|
|
1288
|
+
|
|
1289
|
+
Returns a ``concurrent.futures.Future``. With no *callback* it holds
|
|
1290
|
+
MPV's result or the error, as *command* would have returned or raised.
|
|
1291
|
+
*callback(error, result)* is called first, with *error* None on
|
|
1292
|
+
success, and the future then holds what it returns or raises.
|
|
1293
|
+
|
|
1294
|
+
The callback runs on the event thread, like every other callback
|
|
1295
|
+
here. If an earlier callback ended that thread by raising SystemExit
|
|
1296
|
+
or the like, a warning is logged, and property observers, key
|
|
1297
|
+
bindings and events are no longer delivered at all; command_async
|
|
1298
|
+
callbacks still run, on whichever thread delivers their outcome
|
|
1299
|
+
instead -- the connection's reader while MPV is connected, or the
|
|
1300
|
+
thread that closes the connection -- and a SystemExit from one there
|
|
1301
|
+
is logged, not raised. With the event thread gone, a call made once
|
|
1302
|
+
the connection has closed (by *terminate* or by MPV exiting), or
|
|
1303
|
+
whose send fails, runs its callback on the calling thread before
|
|
1304
|
+
command_async returns, and a SystemExit from it is raised to that
|
|
1305
|
+
caller. A logging handler that raises SystemExit is not caught
|
|
1306
|
+
anywhere: as in the standard library, it ends the thread that logged,
|
|
1307
|
+
and in the close that leaves the futures after it unresolved.
|
|
1308
|
+
|
|
1309
|
+
The future resolves when MPV answers or, if the connection closes
|
|
1310
|
+
first, with BrokenPipeError -- meaning no answer will come, not that
|
|
1311
|
+
the command did not run. Until then it is pending: a command MPV is
|
|
1312
|
+
still running has no timeout.
|
|
1313
|
+
|
|
1314
|
+
Never block on the future from inside a callback here (a property
|
|
1315
|
+
observer, a key binding, an event, another command_async callback).
|
|
1316
|
+
Those run on the event thread, the thread that would resolve it, so
|
|
1317
|
+
the wait never ends. python-mpv behaves the same.
|
|
1318
|
+
|
|
1319
|
+
Once the event thread is gone, a callback must not block at all. On
|
|
1320
|
+
the reader, a sync *command* waits out TIMEOUT with nothing else read
|
|
1321
|
+
meanwhile, and a wait on a future never ends -- nor does a
|
|
1322
|
+
*terminate* called from another thread. python-mpv fails here too:
|
|
1323
|
+
once a callback's SystemExit stops its event loop, no callback or
|
|
1324
|
+
future is delivered at all.
|
|
1325
|
+
|
|
1326
|
+
The future cannot be cancelled: MPV's IPC protocol cannot abort a
|
|
1327
|
+
command. Needs MPV 0.33 or later; an older MPV ignores the request to
|
|
1328
|
+
run in the background and runs the command as *command* does, blocking
|
|
1329
|
+
every other command on the connection until it finishes.
|
|
1330
|
+
"""
|
|
1331
|
+
future = Future()
|
|
1332
|
+
future.set_running_or_notify_cancel()
|
|
1333
|
+
|
|
1334
|
+
def finish(error, result):
|
|
1335
|
+
try:
|
|
1336
|
+
if callback is not None:
|
|
1337
|
+
future.set_result(callback(error, result))
|
|
1338
|
+
elif error is not None:
|
|
1339
|
+
future.set_exception(error)
|
|
1340
|
+
else:
|
|
1341
|
+
future.set_result(result)
|
|
1342
|
+
except BaseException as ex:
|
|
1343
|
+
future.set_exception(ex)
|
|
1344
|
+
if not isinstance(ex, Exception):
|
|
1345
|
+
raise # which thread it reaches, and what happens: see the docstring
|
|
1346
|
+
log.error("command_async callback for {0} raised.".format(
|
|
1347
|
+
command), exc_info=1)
|
|
1348
|
+
|
|
1349
|
+
def on_reply(error, result):
|
|
1350
|
+
if not self.event_handler.put_task(finish, error, result):
|
|
1351
|
+
finish(error, result)
|
|
1352
|
+
|
|
1353
|
+
self.mpv_inter.command_async(on_reply, command, *args)
|
|
1354
|
+
return future
|
|
1355
|
+
|
|
1129
1356
|
def __getattr__(self, name):
|
|
1130
1357
|
if name in self.properties:
|
|
1131
1358
|
return self.command("get_property", name.replace("_", "-"))
|
|
@@ -98,8 +98,10 @@ class ArgumentTranslationTest(unittest.TestCase):
|
|
|
98
98
|
class SocketPathTest(unittest.TestCase):
|
|
99
99
|
@unittest.skipIf(os.name == "nt", "POSIX socket paths only")
|
|
100
100
|
def test_a_stale_socket_file_is_removed_before_starting(self):
|
|
101
|
-
import tempfile
|
|
102
|
-
|
|
101
|
+
import shutil, tempfile
|
|
102
|
+
directory = tempfile.mkdtemp()
|
|
103
|
+
self.addCleanup(shutil.rmtree, directory, True)
|
|
104
|
+
path = os.path.join(directory, "stale.sock")
|
|
103
105
|
with open(path, "w") as handle:
|
|
104
106
|
handle.write("")
|
|
105
107
|
self.assertTrue(os.path.exists(path))
|
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
"""``command_async`` against a real mpv.
|
|
2
|
+
|
|
3
|
+
The case it exists for is a command that takes a long time and must not hold
|
|
4
|
+
the caller or the pipe while it does: jellyfin-mpv-shim's ``sub-add`` of a
|
|
5
|
+
subtitle the server extracts before it answers, which held a synchronous
|
|
6
|
+
``command()`` for the full 120s ``TIMEOUT`` and then raised. A local HTTP
|
|
7
|
+
server that answers late stands in for that server.
|
|
8
|
+
|
|
9
|
+
The pipe half is the one a fake could not show: whether mpv answers *other*
|
|
10
|
+
requests while one is running is mpv's behaviour, and it only does so for a
|
|
11
|
+
request that carries ``"async": true``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import http.server
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import shutil
|
|
19
|
+
import socket
|
|
20
|
+
import sys
|
|
21
|
+
import tempfile
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
import unittest
|
|
25
|
+
from unittest import mock
|
|
26
|
+
|
|
27
|
+
from _harness import LiveMPVTest, python_mpv_jsonipc
|
|
28
|
+
|
|
29
|
+
SRT = b"1\n00:00:00,000 --> 00:10:00,000\nhello\n"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _LateServer:
|
|
33
|
+
"""``/<seconds>/ok.srt`` answers with a subtitle after that long;
|
|
34
|
+
``/<seconds>/missing.srt`` with a 404."""
|
|
35
|
+
|
|
36
|
+
def __init__(self):
|
|
37
|
+
class Handler(http.server.BaseHTTPRequestHandler):
|
|
38
|
+
def do_GET(handler):
|
|
39
|
+
_, delay, name = handler.path.split("/")
|
|
40
|
+
time.sleep(float(delay))
|
|
41
|
+
if name == "ok.srt":
|
|
42
|
+
handler.send_response(200)
|
|
43
|
+
handler.send_header("Content-Length", str(len(SRT)))
|
|
44
|
+
handler.end_headers()
|
|
45
|
+
handler.wfile.write(SRT)
|
|
46
|
+
else:
|
|
47
|
+
handler.send_response(404)
|
|
48
|
+
handler.send_header("Content-Length", "0")
|
|
49
|
+
handler.end_headers()
|
|
50
|
+
|
|
51
|
+
def log_message(handler, *args):
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
self.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
55
|
+
self.server.daemon_threads = True
|
|
56
|
+
threading.Thread(target=self.server.serve_forever, daemon=True).start()
|
|
57
|
+
|
|
58
|
+
def url(self, delay, name="ok.srt"):
|
|
59
|
+
return "http://127.0.0.1:{0}/{1}/{2}".format(
|
|
60
|
+
self.server.server_address[1], delay, name)
|
|
61
|
+
|
|
62
|
+
def close(self):
|
|
63
|
+
self.server.shutdown()
|
|
64
|
+
self.server.server_close()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _wait(predicate, timeout=10):
|
|
68
|
+
deadline = time.monotonic() + timeout
|
|
69
|
+
while time.monotonic() < deadline:
|
|
70
|
+
if predicate():
|
|
71
|
+
return True
|
|
72
|
+
time.sleep(0.05)
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class _AsyncTest(LiveMPVTest):
|
|
77
|
+
def setUp(self):
|
|
78
|
+
super().setUp()
|
|
79
|
+
self.http = _LateServer()
|
|
80
|
+
self.addCleanup(self.http.close)
|
|
81
|
+
self.mpv.play("av://lavfi:testsrc")
|
|
82
|
+
self.assertTrue(_wait(lambda: self.mpv.time_pos is not None),
|
|
83
|
+
"mpv never started playing")
|
|
84
|
+
|
|
85
|
+
def external_subs(self):
|
|
86
|
+
return [t for t in self.mpv.track_list or []
|
|
87
|
+
if t.get("type") == "sub" and t.get("external")]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class CommandAsyncTest(_AsyncTest):
|
|
91
|
+
def test_it_returns_before_the_command_finishes(self):
|
|
92
|
+
started = time.monotonic()
|
|
93
|
+
future = self.mpv.command_async("sub-add", self.http.url(2), "select")
|
|
94
|
+
self.assertLess(time.monotonic() - started, 0.5)
|
|
95
|
+
self.assertFalse(future.done())
|
|
96
|
+
|
|
97
|
+
self.assertIsNone(future.result(timeout=15))
|
|
98
|
+
self.assertGreaterEqual(time.monotonic() - started, 2)
|
|
99
|
+
subs = self.external_subs()
|
|
100
|
+
self.assertEqual(len(subs), 1, subs)
|
|
101
|
+
self.assertTrue(subs[0].get("selected"), subs)
|
|
102
|
+
|
|
103
|
+
def test_other_requests_are_answered_while_it_runs(self):
|
|
104
|
+
# The difference "async" makes on the wire: without it mpv's IPC
|
|
105
|
+
# handler executes the command inline and reads nothing else from
|
|
106
|
+
# this client until it finishes, so this read would wait out the
|
|
107
|
+
# server's delay.
|
|
108
|
+
future = self.mpv.command_async("sub-add", self.http.url(5))
|
|
109
|
+
started = time.monotonic()
|
|
110
|
+
self.assertIsNotNone(self.mpv.pause)
|
|
111
|
+
self.assertLess(time.monotonic() - started, 1)
|
|
112
|
+
self.assertFalse(future.done())
|
|
113
|
+
future.result(timeout=15)
|
|
114
|
+
|
|
115
|
+
def test_a_failure_arrives_as_the_future_exception(self):
|
|
116
|
+
future = self.mpv.command_async(
|
|
117
|
+
"sub-add", self.http.url(0, "missing.srt"))
|
|
118
|
+
self.assertIsInstance(future.exception(timeout=15),
|
|
119
|
+
python_mpv_jsonipc.MPVError)
|
|
120
|
+
|
|
121
|
+
def test_an_unavailable_property_is_none_as_it_is_for_command(self):
|
|
122
|
+
# testsrc has no audio, so mpv answers "property unavailable", which
|
|
123
|
+
# command() turns into None rather than raising.
|
|
124
|
+
self.assertIsNone(self.mpv.command("get_property", "audio-params"))
|
|
125
|
+
future = self.mpv.command_async("get_property", "audio-params")
|
|
126
|
+
self.assertIsNone(future.result(timeout=5))
|
|
127
|
+
|
|
128
|
+
def test_the_callback_sees_the_outcome_and_supplies_the_result(self):
|
|
129
|
+
seen = []
|
|
130
|
+
|
|
131
|
+
def callback(error, result):
|
|
132
|
+
seen.append((error, result, threading.current_thread()))
|
|
133
|
+
return "from the callback"
|
|
134
|
+
|
|
135
|
+
ok = self.mpv.command_async("get_property", "pause", callback=callback)
|
|
136
|
+
self.assertEqual(ok.result(timeout=5), "from the callback")
|
|
137
|
+
bad = self.mpv.command_async(
|
|
138
|
+
"sub-add", self.http.url(0, "missing.srt"), callback=callback)
|
|
139
|
+
self.assertEqual(bad.result(timeout=15), "from the callback")
|
|
140
|
+
|
|
141
|
+
(error, result, thread), (error2, result2, _) = seen
|
|
142
|
+
self.assertIsNone(error)
|
|
143
|
+
self.assertIs(result, False)
|
|
144
|
+
self.assertIsInstance(error2, python_mpv_jsonipc.MPVError)
|
|
145
|
+
self.assertIsNone(result2)
|
|
146
|
+
# Never the transport's reader: user code there stalls every reply.
|
|
147
|
+
self.assertIs(thread, self.mpv.event_handler)
|
|
148
|
+
|
|
149
|
+
def test_a_callback_that_raises_becomes_the_future_exception(self):
|
|
150
|
+
def callback(error, result):
|
|
151
|
+
raise ValueError("boom")
|
|
152
|
+
|
|
153
|
+
with self.assertLogs("mpv-jsonipc", "ERROR"):
|
|
154
|
+
future = self.mpv.command_async("get_property", "pause",
|
|
155
|
+
callback=callback)
|
|
156
|
+
self.assertIsInstance(future.exception(timeout=5), ValueError)
|
|
157
|
+
|
|
158
|
+
def test_it_cannot_be_cancelled(self):
|
|
159
|
+
# The IPC protocol has no way to abort a running command.
|
|
160
|
+
future = self.mpv.command_async("sub-add", self.http.url(2))
|
|
161
|
+
self.assertFalse(future.cancel())
|
|
162
|
+
future.result(timeout=15)
|
|
163
|
+
|
|
164
|
+
def test_loading_another_file_aborts_a_pending_sub_add(self):
|
|
165
|
+
# mpv's behaviour, pinned because the shim relies on it: a subtitle
|
|
166
|
+
# still loading when the next item starts must not land on that item.
|
|
167
|
+
future = self.mpv.command_async("sub-add", self.http.url(5), "select")
|
|
168
|
+
time.sleep(0.5)
|
|
169
|
+
self.mpv.play("av://lavfi:testsrc")
|
|
170
|
+
self.assertIsInstance(future.exception(timeout=15),
|
|
171
|
+
python_mpv_jsonipc.MPVError)
|
|
172
|
+
self.assertTrue(_wait(lambda: self.mpv.time_pos is not None))
|
|
173
|
+
self.assertEqual(self.external_subs(), [])
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class TeardownTest(_AsyncTest):
|
|
177
|
+
"""A closed connection resolves every future, and a callback that
|
|
178
|
+
raises, SystemExit included, strands none. One left pending forever is a
|
|
179
|
+
caller blocked on ``result()`` forever, which is worse than the timeout
|
|
180
|
+
this replaces."""
|
|
181
|
+
|
|
182
|
+
def test_terminate_fails_what_is_pending(self):
|
|
183
|
+
future = self.mpv.command_async("sub-add", self.http.url(30))
|
|
184
|
+
self.mpv.terminate()
|
|
185
|
+
self.assertIsInstance(future.exception(timeout=5), BrokenPipeError)
|
|
186
|
+
|
|
187
|
+
def test_mpv_dying_fails_what_is_pending(self):
|
|
188
|
+
future = self.mpv.command_async("sub-add", self.http.url(30))
|
|
189
|
+
self.mpv.mpv_process.process.kill()
|
|
190
|
+
self.assertIsInstance(future.exception(timeout=5), BrokenPipeError)
|
|
191
|
+
|
|
192
|
+
def test_after_terminate_it_fails_instead_of_hanging(self):
|
|
193
|
+
self.mpv.terminate()
|
|
194
|
+
future = self.mpv.command_async("get_property", "pause")
|
|
195
|
+
self.assertIsInstance(future.exception(timeout=5), BrokenPipeError)
|
|
196
|
+
|
|
197
|
+
def test_the_callback_still_runs_after_terminate(self):
|
|
198
|
+
seen = []
|
|
199
|
+
self.mpv.terminate()
|
|
200
|
+
self.mpv.command_async("get_property", "pause",
|
|
201
|
+
callback=lambda e, r: seen.append(e))
|
|
202
|
+
self.assertTrue(_wait(lambda: seen, 5))
|
|
203
|
+
self.assertIsInstance(seen[0], BrokenPipeError)
|
|
204
|
+
|
|
205
|
+
def test_a_callback_can_terminate(self):
|
|
206
|
+
# The callback runs on the event thread, and terminate() stops that
|
|
207
|
+
# thread: it must not try to join itself.
|
|
208
|
+
future = self.mpv.command_async(
|
|
209
|
+
"get_property", "pause",
|
|
210
|
+
callback=lambda e, r: self.mpv.terminate())
|
|
211
|
+
self.assertIsNone(future.exception(timeout=10))
|
|
212
|
+
self.assertTrue(_wait(lambda: self.mpv.mpv_process.process.poll()
|
|
213
|
+
is not None, 10), "mpv is still running")
|
|
214
|
+
|
|
215
|
+
def test_a_callback_that_exits_strands_nothing(self):
|
|
216
|
+
# A BaseException from a callback ends the event thread, as it always
|
|
217
|
+
# has; what was waiting behind it must still resolve, and so must
|
|
218
|
+
# what comes after.
|
|
219
|
+
def leave(error, result):
|
|
220
|
+
sys.exit(3)
|
|
221
|
+
|
|
222
|
+
first = self.mpv.command_async("get_property", "pause", callback=leave)
|
|
223
|
+
second = self.mpv.command_async("get_property", "pause")
|
|
224
|
+
self.assertIsInstance(first.exception(timeout=5), SystemExit)
|
|
225
|
+
self.assertIs(second.result(timeout=5), False)
|
|
226
|
+
self.assertTrue(_wait(lambda: not self.mpv.event_handler.is_alive(), 5))
|
|
227
|
+
self.assertIs(self.mpv.command_async("get_property", "pause")
|
|
228
|
+
.result(timeout=5), False)
|
|
229
|
+
|
|
230
|
+
def test_a_callback_that_exits_on_the_reader_ends_nothing(self):
|
|
231
|
+
# Once a callback has ended the event thread, later callbacks run on
|
|
232
|
+
# the reader. One that exits there must not end the read loop too:
|
|
233
|
+
# replies still arrive, and mpv dying is still noticed.
|
|
234
|
+
def leave(error, result):
|
|
235
|
+
ran_on.append(threading.current_thread())
|
|
236
|
+
sys.exit(3)
|
|
237
|
+
|
|
238
|
+
ran_on = []
|
|
239
|
+
with self.assertLogs("mpv-jsonipc", "WARNING") as logs:
|
|
240
|
+
self.mpv.command_async("get_property", "pause",
|
|
241
|
+
callback=leave).exception(timeout=5)
|
|
242
|
+
self.assertTrue(
|
|
243
|
+
_wait(lambda: not self.mpv.event_handler.is_alive(), 5))
|
|
244
|
+
self.assertIsInstance(self.mpv.command_async(
|
|
245
|
+
"get_property", "pause", callback=leave).exception(timeout=5),
|
|
246
|
+
SystemExit)
|
|
247
|
+
# Logged after the future resolves, on the reader.
|
|
248
|
+
self.assertTrue(_wait(lambda: any(
|
|
249
|
+
r.levelname == "ERROR" for r in logs.records), 5))
|
|
250
|
+
self.assertIs(ran_on[1], self.mpv.mpv_inter.socket)
|
|
251
|
+
self.assertIn("WARNING", [r.levelname for r in logs.records])
|
|
252
|
+
with mock.patch.object(python_mpv_jsonipc, "TIMEOUT", 3):
|
|
253
|
+
self.assertIs(self.mpv.command("get_property", "pause"), False)
|
|
254
|
+
pending = self.mpv.command_async("sub-add", self.http.url(30))
|
|
255
|
+
self.mpv.mpv_process.process.kill()
|
|
256
|
+
self.assertIsInstance(pending.exception(timeout=5), BrokenPipeError)
|
|
257
|
+
|
|
258
|
+
def test_mpv_dying_fails_a_waiting_command_at_once(self):
|
|
259
|
+
# command() used to wait out TIMEOUT (120 s) for a reply that could
|
|
260
|
+
# no longer come, holding whatever lock its caller held.
|
|
261
|
+
outcome = []
|
|
262
|
+
|
|
263
|
+
def wait():
|
|
264
|
+
try:
|
|
265
|
+
self.mpv.command("sub-add", self.http.url(30))
|
|
266
|
+
except BaseException as error:
|
|
267
|
+
outcome.append(error)
|
|
268
|
+
|
|
269
|
+
waiter = threading.Thread(target=wait, daemon=True)
|
|
270
|
+
waiter.start()
|
|
271
|
+
time.sleep(0.5)
|
|
272
|
+
self.mpv.mpv_process.process.kill()
|
|
273
|
+
waiter.join(10)
|
|
274
|
+
self.assertFalse(waiter.is_alive(), "command() is still waiting")
|
|
275
|
+
self.assertIsInstance(outcome[0], BrokenPipeError)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class ReplyRoutingTest(unittest.TestCase):
|
|
279
|
+
"""The reader thread's side, without a pipe. An exception here escapes
|
|
280
|
+
the transport's read loop, which reports it as the connection dying."""
|
|
281
|
+
|
|
282
|
+
def setUp(self):
|
|
283
|
+
transport = mock.MagicMock()
|
|
284
|
+
patch = mock.patch.object(python_mpv_jsonipc, "UnixSocket", transport)
|
|
285
|
+
patch.start()
|
|
286
|
+
self.addCleanup(patch.stop)
|
|
287
|
+
mock.patch.object(python_mpv_jsonipc, "WindowsSocket",
|
|
288
|
+
transport).start()
|
|
289
|
+
self.addCleanup(mock.patch.stopall)
|
|
290
|
+
self.inter = python_mpv_jsonipc.MPVInter("unused")
|
|
291
|
+
self.quits = []
|
|
292
|
+
self.inter.quit_callback = lambda: self.quits.append(True)
|
|
293
|
+
|
|
294
|
+
def player_whose_event_thread_is_gone(self):
|
|
295
|
+
"""An MPV over the stub, in the state a callback's SystemExit leaves
|
|
296
|
+
it: put_task refuses, so callbacks run on whichever thread delivers
|
|
297
|
+
the reply."""
|
|
298
|
+
player = python_mpv_jsonipc.MPV.__new__(python_mpv_jsonipc.MPV)
|
|
299
|
+
handler = python_mpv_jsonipc.EventHandler()
|
|
300
|
+
handler.stop(join=False)
|
|
301
|
+
for name, value in [("properties", set()), ("mpv_process", None),
|
|
302
|
+
("mpv_inter", self.inter),
|
|
303
|
+
("event_handler", handler)]:
|
|
304
|
+
object.__setattr__(player, name, value)
|
|
305
|
+
return player
|
|
306
|
+
|
|
307
|
+
def request_id(self, n):
|
|
308
|
+
return self.inter.socket.send.call_args_list[n][0][0]["request_id"]
|
|
309
|
+
|
|
310
|
+
def test_a_reply_nobody_is_waiting_for_is_dropped(self):
|
|
311
|
+
# mpv answers a request that carried no request_id with id 0.
|
|
312
|
+
self.inter.event_callback({"request_id": 0, "error": "success"})
|
|
313
|
+
self.inter.event_callback({"request_id": 12345, "error": "success"})
|
|
314
|
+
|
|
315
|
+
def test_an_async_reply_is_delivered_once(self):
|
|
316
|
+
seen = []
|
|
317
|
+
self.inter.command_async(lambda e, r: seen.append((e, r)),
|
|
318
|
+
"get_property", "pause")
|
|
319
|
+
sent = self.inter.socket.send.call_args[0][0]
|
|
320
|
+
self.assertIs(sent["async"], True)
|
|
321
|
+
reply = {"request_id": sent["request_id"], "error": "success",
|
|
322
|
+
"data": True}
|
|
323
|
+
self.inter.event_callback(reply)
|
|
324
|
+
self.inter.event_callback(reply)
|
|
325
|
+
self.inter.stop()
|
|
326
|
+
self.assertEqual(seen, [(None, True)])
|
|
327
|
+
|
|
328
|
+
def test_a_raising_on_reply_loses_nothing_else_on_close(self):
|
|
329
|
+
seen, quits = [], []
|
|
330
|
+
self.inter.quit_callback = lambda: quits.append(True)
|
|
331
|
+
|
|
332
|
+
def bad(error, result):
|
|
333
|
+
raise ValueError("a caller's bug")
|
|
334
|
+
|
|
335
|
+
self.inter.command_async(bad, "get_property", "pause")
|
|
336
|
+
self.inter.command_async(lambda e, r: seen.append(e),
|
|
337
|
+
"get_property", "pause")
|
|
338
|
+
with self.assertLogs("mpv-jsonipc", "ERROR"):
|
|
339
|
+
self.inter._on_quit()
|
|
340
|
+
self.assertEqual(len(seen), 1)
|
|
341
|
+
self.assertIsInstance(seen[0], BrokenPipeError)
|
|
342
|
+
self.assertEqual(quits, [True])
|
|
343
|
+
|
|
344
|
+
def test_a_raising_on_reply_does_not_end_the_read_loop(self):
|
|
345
|
+
def bad(error, result):
|
|
346
|
+
raise ValueError("a caller's bug")
|
|
347
|
+
|
|
348
|
+
self.inter.command_async(bad, "get_property", "pause")
|
|
349
|
+
sent = self.inter.socket.send.call_args[0][0]
|
|
350
|
+
with self.assertLogs("mpv-jsonipc", "ERROR"):
|
|
351
|
+
self.inter.event_callback({"request_id": sent["request_id"],
|
|
352
|
+
"error": "success", "data": True})
|
|
353
|
+
|
|
354
|
+
def test_an_exiting_callback_on_the_reader_is_logged(self):
|
|
355
|
+
player = self.player_whose_event_thread_is_gone()
|
|
356
|
+
first = player.command_async("get_property", "pause",
|
|
357
|
+
callback=lambda e, r: sys.exit(3))
|
|
358
|
+
second = player.command_async("get_property", "pause")
|
|
359
|
+
try:
|
|
360
|
+
with self.assertLogs("mpv-jsonipc", "ERROR"):
|
|
361
|
+
self.inter.event_callback({"request_id": self.request_id(0),
|
|
362
|
+
"error": "success", "data": False})
|
|
363
|
+
except SystemExit:
|
|
364
|
+
self.fail("SystemExit escaped event_callback, ending the reader")
|
|
365
|
+
self.assertIsInstance(first.exception(0), SystemExit)
|
|
366
|
+
self.inter._on_quit()
|
|
367
|
+
self.assertIsInstance(second.exception(0), BrokenPipeError)
|
|
368
|
+
self.assertEqual(self.quits, [True])
|
|
369
|
+
|
|
370
|
+
def test_an_exiting_callback_does_not_cut_the_close_short(self):
|
|
371
|
+
player = self.player_whose_event_thread_is_gone()
|
|
372
|
+
first = player.command_async("get_property", "pause",
|
|
373
|
+
callback=lambda e, r: sys.exit(3))
|
|
374
|
+
rest = [player.command_async("get_property", "pause")
|
|
375
|
+
for _ in range(2)]
|
|
376
|
+
try:
|
|
377
|
+
with self.assertLogs("mpv-jsonipc", "ERROR"):
|
|
378
|
+
self.inter._on_quit()
|
|
379
|
+
except SystemExit:
|
|
380
|
+
self.fail("SystemExit escaped the close sweep")
|
|
381
|
+
self.assertIsInstance(first.exception(0), SystemExit)
|
|
382
|
+
for future in rest:
|
|
383
|
+
self.assertIsInstance(future.exception(0), BrokenPipeError)
|
|
384
|
+
self.assertEqual(self.quits, [True])
|
|
385
|
+
|
|
386
|
+
def test_terminate_logs_an_exiting_callback_instead_of_raising_it(self):
|
|
387
|
+
# The callback runs on terminate()'s caller here, but it is the
|
|
388
|
+
# library's close doing the calling: the rest must still resolve.
|
|
389
|
+
player = self.player_whose_event_thread_is_gone()
|
|
390
|
+
first = player.command_async("get_property", "pause",
|
|
391
|
+
callback=lambda e, r: sys.exit(3))
|
|
392
|
+
second = player.command_async("get_property", "pause")
|
|
393
|
+
try:
|
|
394
|
+
with self.assertLogs("mpv-jsonipc", "ERROR"):
|
|
395
|
+
player.terminate()
|
|
396
|
+
except SystemExit:
|
|
397
|
+
self.fail("SystemExit raised out of terminate()")
|
|
398
|
+
self.assertIsInstance(first.exception(0), SystemExit)
|
|
399
|
+
self.assertIsInstance(second.exception(0), BrokenPipeError)
|
|
400
|
+
|
|
401
|
+
def test_a_raising_event_callback_is_logged(self):
|
|
402
|
+
def bad(event, data):
|
|
403
|
+
raise ValueError("a caller's bug")
|
|
404
|
+
|
|
405
|
+
self.inter.callback = bad
|
|
406
|
+
try:
|
|
407
|
+
with self.assertLogs("mpv-jsonipc", "ERROR"):
|
|
408
|
+
self.inter.event_callback({"event": "pause"})
|
|
409
|
+
except ValueError:
|
|
410
|
+
self.fail("the event callback's exception escaped event_callback")
|
|
411
|
+
|
|
412
|
+
def test_a_command_after_the_reader_died_fails_at_once(self):
|
|
413
|
+
# The reader's quit sweeps before anything stops the socket, so a
|
|
414
|
+
# send can still "succeed" into a dead connection in between.
|
|
415
|
+
self.inter._on_quit()
|
|
416
|
+
with mock.patch.object(python_mpv_jsonipc, "TIMEOUT", 2):
|
|
417
|
+
with self.assertRaises(BrokenPipeError):
|
|
418
|
+
self.inter.command("get_property", "pause")
|
|
419
|
+
|
|
420
|
+
def test_a_send_that_fails_is_reported_to_the_callback(self):
|
|
421
|
+
seen = []
|
|
422
|
+
self.inter.socket.send.side_effect = BrokenPipeError("gone")
|
|
423
|
+
self.inter.command_async(lambda e, r: seen.append(e), "stop")
|
|
424
|
+
self.assertEqual(len(seen), 1)
|
|
425
|
+
self.assertIsInstance(seen[0], BrokenPipeError)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
@unittest.skipIf(os.name == "nt", "the reader there is a named pipe")
|
|
430
|
+
class RealReaderTest(unittest.TestCase):
|
|
431
|
+
"""The stub above cannot show whether the read loop goes on afterwards;
|
|
432
|
+
a real UnixSocket reader against a fake mpv can."""
|
|
433
|
+
|
|
434
|
+
def test_an_exiting_event_callback_does_not_end_the_read_loop(self):
|
|
435
|
+
directory = tempfile.mkdtemp()
|
|
436
|
+
self.addCleanup(shutil.rmtree, directory, True)
|
|
437
|
+
path = os.path.join(directory, "mpv.sock")
|
|
438
|
+
server = socket.socket(socket.AF_UNIX)
|
|
439
|
+
server.bind(path)
|
|
440
|
+
server.listen(1)
|
|
441
|
+
self.addCleanup(server.close)
|
|
442
|
+
accepted = []
|
|
443
|
+
threading.Thread(target=lambda: accepted.append(server.accept()[0]),
|
|
444
|
+
daemon=True).start()
|
|
445
|
+
|
|
446
|
+
def leave(event, data):
|
|
447
|
+
raise SystemExit(3)
|
|
448
|
+
|
|
449
|
+
inter = python_mpv_jsonipc.MPVInter(path, callback=leave)
|
|
450
|
+
self.addCleanup(inter.stop)
|
|
451
|
+
self.assertTrue(_wait(lambda: accepted, 5))
|
|
452
|
+
peer = accepted[0]
|
|
453
|
+
self.addCleanup(peer.close)
|
|
454
|
+
seen = []
|
|
455
|
+
inter.command_async(lambda e, r: seen.append(r),
|
|
456
|
+
"get_property", "pause")
|
|
457
|
+
request = json.loads(peer.recv(4096).split(b"\n")[0])
|
|
458
|
+
with self.assertLogs("mpv-jsonipc", "ERROR"):
|
|
459
|
+
peer.sendall(b'{"event": "pause"}\n')
|
|
460
|
+
peer.sendall(json.dumps({"request_id": request["request_id"],
|
|
461
|
+
"error": "success", "data": 7})
|
|
462
|
+
.encode() + b"\n")
|
|
463
|
+
self.assertTrue(_wait(lambda: seen, 3),
|
|
464
|
+
"the reply after the event was never read")
|
|
465
|
+
self.assertEqual(seen, [7])
|
|
466
|
+
self.assertTrue(inter.socket.is_alive())
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
class _BadRepr:
|
|
470
|
+
"""A task's object whose repr raises: logging it must not cost the
|
|
471
|
+
tasks queued after it."""
|
|
472
|
+
|
|
473
|
+
def __repr__(self):
|
|
474
|
+
raise ValueError("bad repr")
|
|
475
|
+
|
|
476
|
+
def method(self):
|
|
477
|
+
pass
|
|
478
|
+
|
|
479
|
+
def raises(self):
|
|
480
|
+
raise RuntimeError("a caller's bug")
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
class EventThreadTest(unittest.TestCase):
|
|
484
|
+
def records(self):
|
|
485
|
+
seen = []
|
|
486
|
+
handler = logging.Handler()
|
|
487
|
+
handler.emit = seen.append
|
|
488
|
+
logger = logging.getLogger("mpv-jsonipc")
|
|
489
|
+
logger.addHandler(handler)
|
|
490
|
+
self.addCleanup(logger.removeHandler, handler)
|
|
491
|
+
return seen
|
|
492
|
+
|
|
493
|
+
def test_a_callback_ending_the_thread_warns_once(self):
|
|
494
|
+
seen = self.records()
|
|
495
|
+
handler = python_mpv_jsonipc.EventHandler()
|
|
496
|
+
handler.start()
|
|
497
|
+
handler.put_task(sys.exit, 3)
|
|
498
|
+
handler.join(5)
|
|
499
|
+
self.assertFalse(handler.is_alive())
|
|
500
|
+
warnings = [r for r in seen if r.levelno == logging.WARNING]
|
|
501
|
+
self.assertEqual(len(warnings), 1, [r.getMessage() for r in seen])
|
|
502
|
+
|
|
503
|
+
def test_an_ordinary_stop_does_not_warn(self):
|
|
504
|
+
seen = self.records()
|
|
505
|
+
handler = python_mpv_jsonipc.EventHandler()
|
|
506
|
+
handler.start()
|
|
507
|
+
handler.stop(5)
|
|
508
|
+
self.assertEqual([r for r in seen if r.levelno >= logging.WARNING], [])
|
|
509
|
+
|
|
510
|
+
def test_a_task_whose_repr_raises_strands_nothing_behind_it(self):
|
|
511
|
+
seen = []
|
|
512
|
+
handler = python_mpv_jsonipc.EventHandler()
|
|
513
|
+
handler.put_task(sys.exit, 3)
|
|
514
|
+
handler.put_task(_BadRepr().method)
|
|
515
|
+
handler.put_task(seen.append, "after")
|
|
516
|
+
self.records()
|
|
517
|
+
handler.start()
|
|
518
|
+
handler.join(5)
|
|
519
|
+
self.assertEqual(seen, ["after"])
|
|
520
|
+
|
|
521
|
+
def test_a_raising_task_whose_repr_raises_does_not_end_the_thread(self):
|
|
522
|
+
seen = self.records()
|
|
523
|
+
handler = python_mpv_jsonipc.EventHandler()
|
|
524
|
+
handler.start()
|
|
525
|
+
self.addCleanup(handler.stop, 5)
|
|
526
|
+
handler.put_task(_BadRepr().raises)
|
|
527
|
+
self.assertTrue(_wait(lambda: seen, 5))
|
|
528
|
+
time.sleep(0.1)
|
|
529
|
+
self.assertTrue(handler.is_alive(), "the event thread ended")
|
|
530
|
+
self.assertEqual([r.levelno for r in seen], [logging.ERROR])
|
|
531
|
+
|
|
532
|
+
def test_what_was_queued_behind_an_exit_still_runs(self):
|
|
533
|
+
# The first exit ends run(); the rest goes to _drain, which must
|
|
534
|
+
# survive the second and still pass the third its arguments.
|
|
535
|
+
seen = []
|
|
536
|
+
handler = python_mpv_jsonipc.EventHandler()
|
|
537
|
+
handler.put_task(sys.exit, 3)
|
|
538
|
+
handler.put_task(sys.exit, 4)
|
|
539
|
+
handler.put_task(seen.append, "after")
|
|
540
|
+
with self.assertLogs("mpv-jsonipc", "ERROR") as logs:
|
|
541
|
+
handler.start()
|
|
542
|
+
handler.join(5)
|
|
543
|
+
self.assertFalse(handler.is_alive())
|
|
544
|
+
self.assertEqual(seen, ["after"])
|
|
545
|
+
self.assertTrue(any(r.exc_info and r.exc_info[0] is SystemExit
|
|
546
|
+
for r in logs.records))
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
if __name__ == "__main__":
|
|
550
|
+
unittest.main()
|
|
@@ -30,6 +30,7 @@ that the cheap tests here cover postconditions, not interleavings.
|
|
|
30
30
|
"""
|
|
31
31
|
|
|
32
32
|
import os
|
|
33
|
+
import shutil
|
|
33
34
|
import tempfile
|
|
34
35
|
import threading
|
|
35
36
|
import time
|
|
@@ -141,6 +142,8 @@ class TerminateWaitsTest(unittest.TestCase):
|
|
|
141
142
|
spawned.append(process)
|
|
142
143
|
return process
|
|
143
144
|
|
|
145
|
+
directory = tempfile.mkdtemp()
|
|
146
|
+
self.addCleanup(shutil.rmtree, directory, True)
|
|
144
147
|
with mock.patch.object(python_mpv_jsonipc.subprocess, "Popen", record), \
|
|
145
148
|
mock.patch.object(python_mpv_jsonipc, "_ipc_endpoint_ready",
|
|
146
149
|
return_value=False), \
|
|
@@ -148,7 +151,7 @@ class TerminateWaitsTest(unittest.TestCase):
|
|
|
148
151
|
_skip_only_the_readiness_poll()):
|
|
149
152
|
with self.assertRaises(python_mpv_jsonipc.MPVProcessError):
|
|
150
153
|
python_mpv_jsonipc.MPVProcess(
|
|
151
|
-
os.path.join(
|
|
154
|
+
os.path.join(directory, "failed-start.sock"),
|
|
152
155
|
MPV_BINARY, **LIVE_OPTIONS)
|
|
153
156
|
|
|
154
157
|
self.assertEqual(len(spawned), 1)
|
|
@@ -178,6 +181,25 @@ class TerminateWaitsTest(unittest.TestCase):
|
|
|
178
181
|
|
|
179
182
|
self.assertEqual(escaped, [])
|
|
180
183
|
|
|
184
|
+
def test_a_raising_quit_callback_still_terminates(self):
|
|
185
|
+
# A raise there used to skip the terminate(join=False) after it,
|
|
186
|
+
# leaving the event thread running with MPV gone.
|
|
187
|
+
mpv = start_mpv()
|
|
188
|
+
self.addCleanup(mpv.terminate)
|
|
189
|
+
|
|
190
|
+
def bad():
|
|
191
|
+
raise ValueError("a caller's bug")
|
|
192
|
+
|
|
193
|
+
mpv.quit_callback = bad
|
|
194
|
+
with self.assertLogs("mpv-jsonipc", "ERROR"):
|
|
195
|
+
mpv.mpv_process.process.kill()
|
|
196
|
+
deadline = time.time() + 5
|
|
197
|
+
while mpv.event_handler.is_alive() and time.time() < deadline:
|
|
198
|
+
time.sleep(0.02)
|
|
199
|
+
self.assertFalse(
|
|
200
|
+
mpv.event_handler.is_alive(),
|
|
201
|
+
"terminate never ran: the event thread is still alive")
|
|
202
|
+
|
|
181
203
|
def test_the_process_is_reaped_not_merely_signalled(self):
|
|
182
204
|
mpv = start_mpv()
|
|
183
205
|
process = mpv.mpv_process.process
|
|
@@ -213,7 +235,9 @@ class StopWaitingTest(unittest.TestCase):
|
|
|
213
235
|
process = python_mpv_jsonipc.MPVProcess.__new__(
|
|
214
236
|
python_mpv_jsonipc.MPVProcess)
|
|
215
237
|
process.process = self._Stubborn()
|
|
216
|
-
|
|
238
|
+
directory = tempfile.mkdtemp()
|
|
239
|
+
self.addCleanup(shutil.rmtree, directory, True)
|
|
240
|
+
process.ipc_socket = os.path.join(directory, socket_name)
|
|
217
241
|
return process
|
|
218
242
|
|
|
219
243
|
def test_a_timeout_waits_then_escalates_to_kill(self):
|
|
File without changes
|
|
File without changes
|
{python_mpv_jsonipc-1.4.0 → python_mpv_jsonipc-1.5.0}/python_mpv_jsonipc.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|