python-mpv-jsonipc 1.2.3__tar.gz → 1.3.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-mpv-jsonipc
3
- Version: 1.2.3
3
+ Version: 1.3.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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-mpv-jsonipc"
7
- version = "1.2.3"
7
+ version = "1.3.0"
8
8
  description = "Python API to MPV using JSON IPC"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-mpv-jsonipc
3
- Version: 1.2.3
3
+ Version: 1.3.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
@@ -0,0 +1,14 @@
1
+ LICENSE.md
2
+ README.md
3
+ pyproject.toml
4
+ python_mpv_jsonipc.py
5
+ python_mpv_jsonipc.egg-info/PKG-INFO
6
+ python_mpv_jsonipc.egg-info/SOURCES.txt
7
+ python_mpv_jsonipc.egg-info/dependency_links.txt
8
+ python_mpv_jsonipc.egg-info/top_level.txt
9
+ tests/test_api_surface.py
10
+ tests/test_arg_building.py
11
+ tests/test_live_basic.py
12
+ tests/test_live_events.py
13
+ tests/test_live_failure.py
14
+ tests/test_python_floor.py
@@ -2,6 +2,7 @@ import threading
2
2
  import socket
3
3
  import json
4
4
  import os
5
+ import re
5
6
  import time
6
7
  import subprocess
7
8
  import random
@@ -35,6 +36,79 @@ class MPVError(Exception):
35
36
  def __init__(self, *args, **kwargs):
36
37
  super(MPVError, self).__init__(*args, **kwargs)
37
38
 
39
+ class MPVProcessError(MPVError):
40
+ """MPV would not start, with whatever MPV said about why.
41
+
42
+ A subclass so that every existing ``except MPVError`` keeps working
43
+ unchanged -- this library is depended on by projects that will never be
44
+ updated, and several of them catch only that.
45
+
46
+ *returncode* is MPV's exit status, if it got far enough to have one.
47
+ *bad_option* is the option MPV refused, when that could be determined.
48
+ *log_output* is what MPV wrote about the failure.
49
+ *retryable* is False when starting again cannot possibly work.
50
+ *argv* is the command line that was tried.
51
+ """
52
+ def __init__(self, message, returncode=None, bad_option=None,
53
+ log_output=None, retryable=True, argv=None):
54
+ super(MPVProcessError, self).__init__(message)
55
+ self.returncode = returncode
56
+ self.bad_option = bad_option
57
+ self.log_output = log_output
58
+ self.retryable = retryable
59
+ self.argv = argv
60
+
61
+ #: MPV's own wording when it is handed an option it does not have. Matching
62
+ #: it is what turns "MPV exited" into "MPV has no --input-gamepad".
63
+ _OPTION_ERROR = re.compile(r"Error parsing option ([^\s]+) \(option not found\)")
64
+
65
+ def _diagnose_start_failure(argv, timeout=15):
66
+ """Ask MPV why it refused *argv*. Returns ``(bad_option, output)``.
67
+
68
+ Three details make this reliable, and each was measured rather than
69
+ reasoned about:
70
+
71
+ * **The message comes back on stdout, not stderr.** MPV writes its
72
+ terminal output to stdout; stderr is empty. Capturing the wrong stream
73
+ gets a confident, permanent "no idea why".
74
+ * **``--terminal=yes`` has to come first.** Options are parsed in order
75
+ and this library sets ``--terminal=no`` by default, so with the flags
76
+ left as they are MPV has already silenced itself by the time it reaches
77
+ the option it objects to. The library's own terminal flags are dropped
78
+ rather than fought with.
79
+ * **``--version`` goes last**, so a configuration that turns out to be
80
+ fine parses everything and then exits (~15ms) instead of starting up
81
+ and having to be killed. It exits before binding the IPC socket, so
82
+ this cannot disturb the real MPV that is about to start.
83
+
84
+ An earlier version of this read ``--log-file`` instead, on the theory
85
+ that Windows has no stderr to capture. That log is written
86
+ **unreliably**: MPV does not flush it before exiting, so it arrives
87
+ truncated at a random point -- measured at 6/8 and 7/8 runs containing
88
+ the error, cut off anywhere from 102 bytes in. A pipe has no such
89
+ problem, and is byte-identical across runs.
90
+
91
+ Every failure here is answered with ``(None, ...)``, which puts the
92
+ caller back on the path it took before this existed. Being unable to
93
+ explain a failed start must never turn into a second failure.
94
+ """
95
+ terminal_flags = ("--terminal=", "--input-terminal=")
96
+ argv = list(argv)
97
+ probe = ([argv[0], "--terminal=yes"]
98
+ + [a for a in argv[1:] if not a.startswith(terminal_flags)]
99
+ + ["--version"])
100
+ try:
101
+ completed = subprocess.run(probe,
102
+ stdout=subprocess.PIPE,
103
+ stderr=subprocess.STDOUT,
104
+ timeout=timeout)
105
+ except Exception:
106
+ log.debug("Could not run MPV to diagnose the failure.", exc_info=1)
107
+ return None, None
108
+ output = completed.stdout.decode("utf-8", "replace")
109
+ match = _OPTION_ERROR.search(output)
110
+ return (match.group(1) if match else None), output
111
+
38
112
  class WindowsSocket(threading.Thread):
39
113
  """
40
114
  Wraps a Windows named pipe in a high-level interface. (Internal)
@@ -198,6 +272,26 @@ class UnixSocket(threading.Thread):
198
272
  if self.quit_callback:
199
273
  self.quit_callback()
200
274
 
275
+ def _ipc_endpoint_ready(ipc_socket):
276
+ """Return True once MPV's IPC endpoint is available.
277
+
278
+ On POSIX the endpoint is a filesystem socket, so ``os.path.exists`` works. On
279
+ Windows it is a *named pipe*, for which ``os.path.exists`` is always False — so we
280
+ probe it with ``WaitNamedPipe`` instead. Using ``os.path.exists`` on Windows made
281
+ the startup poll never detect the pipe, raising a spurious "MPV start timed out".
282
+ """
283
+ if os.name != 'nt':
284
+ return os.path.exists(ipc_socket)
285
+ try:
286
+ _winapi.WaitNamedPipe(ipc_socket, 0)
287
+ return True
288
+ except FileNotFoundError:
289
+ return False
290
+ except OSError:
291
+ # Pipe exists but all instances are momentarily busy — it is present.
292
+ return True
293
+
294
+
201
295
  class MPVProcess:
202
296
  """
203
297
  Manages an MPV process, ensuring the socket or pipe is available. (Internal)
@@ -243,12 +337,13 @@ class MPVProcess:
243
337
 
244
338
  args.extend("--{0}={1}".format(v[0].replace("_", "-"), self._mpv_fmt(v[1]))
245
339
  for v in arg_pairs)
340
+ self.argv = args
246
341
  self.process = subprocess.Popen(args)
247
342
  ipc_exists = False
248
343
  for _ in range(100): # Give MPV 10 seconds to start.
249
344
  time.sleep(0.1)
250
345
  self.process.poll()
251
- if os.path.exists(ipc_socket):
346
+ if _ipc_endpoint_ready(ipc_socket):
252
347
  ipc_exists = True
253
348
  log.debug("Found MPV socket.")
254
349
  break
@@ -257,11 +352,16 @@ class MPVProcess:
257
352
  break
258
353
  else:
259
354
  self.process.terminate()
260
- raise MPVError("MPV start timed out.")
355
+ raise MPVProcessError("MPV start timed out.", argv=args)
261
356
 
262
357
  if not ipc_exists or self.process.returncode is not None:
263
358
  self.process.terminate()
264
- raise MPVError("MPV not started.")
359
+ # Message unchanged: it has been this string for years and is not
360
+ # ours to break. What is new is the argv riding along, which is
361
+ # what lets the caller ask MPV why.
362
+ raise MPVProcessError("MPV not started.",
363
+ returncode=self.process.returncode,
364
+ argv=args)
265
365
 
266
366
  def _set_default(self, prop_dict, key, value):
267
367
  if key not in prop_dict:
@@ -409,7 +509,8 @@ class MPV:
409
509
  list is used. Not all commands may actually work when this fallback is used.
410
510
  """
411
511
  def __init__(self, start_mpv=True, ipc_socket=None, mpv_location=None,
412
- log_handler=None, loglevel=None, quit_callback=None, start_retries=5, start_retry_delay_ms=1000, **kwargs):
512
+ log_handler=None, loglevel=None, quit_callback=None, start_retries=5, start_retry_delay_ms=1000,
513
+ diagnose_start_failures=True, **kwargs):
413
514
  """
414
515
  Create the interface to MPV and process instance.
415
516
 
@@ -419,6 +520,10 @@ class MPV:
419
520
  *log_handler(level, prefix, text)* is an optional handler for log events. (Default: Disabled)
420
521
  *loglevel* is the level for log messages. Levels are fatal, error, warn, info, v, debug, trace. (Default: Disabled)
421
522
  *quit_callback* is called when the socket connection to MPV dies.
523
+ *diagnose_start_failures* re-runs MPV once, on the first failed start, to find out
524
+ why it refused to start. If MPV names an option it does not have, MPVProcessError is
525
+ raised immediately with *bad_option* set instead of spending every retry on something
526
+ that cannot succeed. Set it to False for the pre-1.3 behaviour. (Default: True)
422
527
 
423
528
  All other arguments are forwarded to MPV as command-line arguments if *start_mpv* is used.
424
529
  """
@@ -440,16 +545,56 @@ class MPV:
440
545
 
441
546
  if start_mpv:
442
547
  # Attempt to start MPV multiple times.
548
+ last_error = None
549
+ diagnosed = False
550
+ log_output = None
443
551
  for i in range(start_retries):
444
552
  try:
445
553
  self.mpv_process = MPVProcess(ipc_socket, mpv_location, **kwargs)
446
554
  break
447
- except MPVError:
555
+ except MPVError as error:
556
+ last_error = error
448
557
  log.warning("MPV start failed.", exc_info=1)
558
+
559
+ # Ask MPV why, once, on the first failure. An option MPV
560
+ # does not have cannot start on the fifth attempt either,
561
+ # and spending the whole retry budget on it produces the
562
+ # least useful error this library has ever raised: a
563
+ # timeout that names nothing. The probe costs one ~20ms
564
+ # MPV run and only ever happens on a start that has
565
+ # already failed.
566
+ argv = getattr(error, "argv", None)
567
+ if diagnose_start_failures and argv and not diagnosed:
568
+ diagnosed = True
569
+ bad_option, diagnosis = _diagnose_start_failure(argv)
570
+ # Keep what MPV said even when it does not name an
571
+ # option. Refusing to start over a missing DLL or an
572
+ # unusable VO is the same predicament from the user's
573
+ # side -- something they changed, and nothing to go
574
+ # on -- and on Windows there is no console for them to
575
+ # have seen it in. It rides out on .log_output below.
576
+ log_output = diagnosis
577
+ if bad_option is not None:
578
+ raise MPVProcessError(
579
+ "MPV rejected the option --{0}.".format(
580
+ bad_option),
581
+ returncode=getattr(error, "returncode", None),
582
+ bad_option=bad_option,
583
+ log_output=log_output,
584
+ retryable=False,
585
+ argv=argv)
586
+
449
587
  time.sleep(start_retry_delay_ms / 1000)
450
588
  continue
451
589
  else:
452
- raise MPVError("MPV process retry limit reached.")
590
+ # The message is unchanged on purpose. Downstream projects
591
+ # that will never be updated may be matching on it, and this
592
+ # is still the same situation it has always described.
593
+ raise MPVProcessError(
594
+ "MPV process retry limit reached.",
595
+ returncode=getattr(last_error, "returncode", None),
596
+ log_output=log_output,
597
+ argv=getattr(last_error, "argv", None))
453
598
 
454
599
  self.mpv_inter = MPVInter(ipc_socket, self._callback, self._quit_callback)
455
600
  self.properties = set(x.replace("-", "_") for x in self.command("get_property", "property-list"))
@@ -0,0 +1,136 @@
1
+ """The public surface, frozen.
2
+
3
+ Roughly 78 repositories depend on this library, some unmaintained, and a name
4
+ or keyword argument that quietly changes shape breaks them at *their* users'
5
+ machines rather than here. Keyword names matter as much as the names
6
+ themselves: callers pass `mpv_location=`, `quit_callback=`, `start_retries=`
7
+ by keyword, so a rename is a break even when the parameter still exists.
8
+
9
+ This is a snapshot, not a judgement. Nothing here says the surface is good --
10
+ only that it is what shipped. Changing it deliberately means regenerating the
11
+ golden file in the same commit, which is what makes the change visible in
12
+ review instead of six months later in someone's issue tracker.
13
+
14
+ python3 tests/test_api_surface.py --update
15
+ """
16
+
17
+ import inspect
18
+ import json
19
+ import os
20
+ import sys
21
+ import unittest
22
+
23
+ from _harness import python_mpv_jsonipc
24
+
25
+ GOLDEN = os.path.join(os.path.dirname(os.path.abspath(__file__)),
26
+ "api_surface.json")
27
+
28
+
29
+ def _signature(func):
30
+ try:
31
+ return str(inspect.signature(func))
32
+ except (TypeError, ValueError): # pragma: no cover - C callables
33
+ return "(?)"
34
+
35
+
36
+ def describe():
37
+ """The public surface as plain data, stable under dict ordering."""
38
+ module = python_mpv_jsonipc
39
+ surface = {"classes": {}, "functions": {}, "constants": {}}
40
+
41
+ for name in sorted(dir(module)):
42
+ if name.startswith("_"):
43
+ continue
44
+ value = getattr(module, name)
45
+ if inspect.isclass(value):
46
+ if value.__module__ != module.__name__:
47
+ continue # imported, not ours
48
+ # Only what the class itself defines. `dir()` would drag in
49
+ # inherited stdlib members -- `Thread.start`, and for exceptions
50
+ # `with_traceback` and `add_note` -- whose introspectability
51
+ # varies by interpreter version: 3.13 reports
52
+ # `(self, object, /)` where 3.11 raises and we record `(?)`.
53
+ # That made the golden file disagree with itself across the
54
+ # Python matrix while nothing about this library had changed.
55
+ # Inheritance is still recorded, in `bases`.
56
+ methods = {}
57
+ for attr, member in sorted(vars(value).items()):
58
+ if attr.startswith("_") and attr != "__init__":
59
+ continue
60
+ if callable(member):
61
+ methods[attr] = _signature(member)
62
+ surface["classes"][name] = {
63
+ "bases": [b.__name__ for b in value.__bases__],
64
+ "methods": methods,
65
+ }
66
+ elif inspect.isfunction(value):
67
+ surface["functions"][name] = _signature(value)
68
+ elif isinstance(value, (int, float, str, bool, list, tuple)):
69
+ surface["constants"][name] = value
70
+
71
+ return surface
72
+
73
+
74
+ class ApiSurfaceTest(unittest.TestCase):
75
+ def test_the_public_surface_matches_the_golden_file(self):
76
+ with open(GOLDEN) as handle:
77
+ expected = json.load(handle)
78
+ actual = describe()
79
+ self.assertEqual(
80
+ expected, actual,
81
+ "The public API changed. If that was deliberate, regenerate the "
82
+ "golden file with `python3 tests/test_api_surface.py --update` "
83
+ "in the same commit -- and consider whether downstream users who "
84
+ "never read the changelog will survive it.")
85
+
86
+ def test_the_names_downstream_actually_imports_are_present(self):
87
+ # Named individually as well as by snapshot, so a wholesale
88
+ # regeneration of the golden file cannot silently bless a removal.
89
+ for name in ("MPV", "MPVError", "MPVProcess", "MPVInter",
90
+ "EventHandler", "UnixSocket"):
91
+ self.assertTrue(hasattr(python_mpv_jsonipc, name),
92
+ "{0} disappeared from the public surface".format(name))
93
+
94
+ def test_mpv_error_stays_an_exception(self):
95
+ self.assertTrue(issubclass(python_mpv_jsonipc.MPVError, Exception))
96
+ # Downstream catches it and reads str(); both are contract.
97
+ self.assertEqual(str(python_mpv_jsonipc.MPVError("boom")), "boom")
98
+
99
+ def test_the_constructor_keywords_keep_their_names_and_defaults(self):
100
+ parameters = inspect.signature(python_mpv_jsonipc.MPV.__init__).parameters
101
+ self.assertEqual(parameters["start_mpv"].default, True)
102
+ self.assertEqual(parameters["ipc_socket"].default, None)
103
+ self.assertEqual(parameters["mpv_location"].default, None)
104
+ self.assertEqual(parameters["log_handler"].default, None)
105
+ self.assertEqual(parameters["loglevel"].default, None)
106
+ self.assertEqual(parameters["quit_callback"].default, None)
107
+ self.assertEqual(parameters["start_retries"].default, 5)
108
+ self.assertEqual(parameters["start_retry_delay_ms"].default, 1000)
109
+ self.assertEqual(parameters["kwargs"].kind,
110
+ inspect.Parameter.VAR_KEYWORD)
111
+
112
+
113
+ class FallbackCommandListTest(unittest.TestCase):
114
+ """Only reachable on very old mpv, and therefore never exercised by the
115
+ live tests -- which is exactly why it needs pinning here."""
116
+
117
+ def test_it_still_contains_what_a_client_needs_to_play_something(self):
118
+ commands = python_mpv_jsonipc.FALLBACK_COMMAND_LIST
119
+ for essential in ("loadfile", "quit", "seek", "stop", "set",
120
+ "keybind", "script-message"):
121
+ self.assertIn(essential, commands)
122
+
123
+ def test_it_is_a_list_of_plain_strings(self):
124
+ self.assertIsInstance(python_mpv_jsonipc.FALLBACK_COMMAND_LIST, list)
125
+ for command in python_mpv_jsonipc.FALLBACK_COMMAND_LIST:
126
+ self.assertIsInstance(command, str)
127
+
128
+
129
+ if __name__ == "__main__":
130
+ if "--update" in sys.argv:
131
+ with open(GOLDEN, "w") as handle:
132
+ json.dump(describe(), handle, indent=2, sort_keys=True)
133
+ handle.write("\n")
134
+ print("wrote {0}".format(GOLDEN))
135
+ sys.exit(0)
136
+ unittest.main()
@@ -0,0 +1,125 @@
1
+ """How Python kwargs become mpv command-line arguments.
2
+
3
+ Characterization, not aspiration: these assert what the library does today,
4
+ including the parts that look accidental. The argv is the whole contract
5
+ between this library and mpv, and downstream code passes options through it
6
+ that neither this repo nor its author has ever seen -- so the mapping is
7
+ pinned exactly, order included.
8
+
9
+ No mpv runs here. ``Popen`` is captured and the endpoint poll is answered
10
+ ``True``, which is honest for a test that only ever reads back the argv; see
11
+ ``_harness`` for why nothing about lifecycle is tested this way.
12
+ """
13
+
14
+ import os
15
+ import unittest
16
+ from unittest import mock
17
+
18
+ from _harness import python_mpv_jsonipc
19
+
20
+
21
+ class _FakeProcess:
22
+ returncode = None
23
+
24
+ def poll(self):
25
+ return None
26
+
27
+ def terminate(self):
28
+ pass
29
+
30
+
31
+ def build_argv(ipc_socket="/tmp/testsocket", mpv_location="mpv", **kwargs):
32
+ """The argv ``MPVProcess`` would have spawned, without spawning it."""
33
+ with mock.patch.object(python_mpv_jsonipc.subprocess, "Popen") as popen, \
34
+ mock.patch.object(python_mpv_jsonipc, "_ipc_endpoint_ready",
35
+ return_value=True):
36
+ popen.return_value = _FakeProcess()
37
+ python_mpv_jsonipc.MPVProcess(ipc_socket, mpv_location, **kwargs)
38
+ return popen.call_args[0][0]
39
+
40
+
41
+ class ArgumentTranslationTest(unittest.TestCase):
42
+ def test_the_binary_leads_and_defaults_follow_the_callers_options(self):
43
+ # Order is observable and therefore pinned. `_set_default` appends,
44
+ # so anything the caller did not pass arrives after what they did,
45
+ # in the order the defaults are applied.
46
+ self.assertEqual(build_argv(mpv_location="/usr/bin/mpv"), [
47
+ "/usr/bin/mpv",
48
+ "--idle=yes",
49
+ "--input-ipc-server=/tmp/testsocket",
50
+ "--input-terminal=no",
51
+ "--terminal=no",
52
+ ])
53
+
54
+ def test_underscores_become_dashes(self):
55
+ self.assertIn("--hwdec-codecs=all", build_argv(hwdec_codecs="all"))
56
+
57
+ def test_booleans_become_yes_and_no(self):
58
+ argv = build_argv(fullscreen=True, border=False)
59
+ self.assertIn("--fullscreen=yes", argv)
60
+ self.assertIn("--border=no", argv)
61
+
62
+ def test_numbers_are_not_booleans(self):
63
+ # 1 == True and 0 == False in Python, so an `==` comparison turns
64
+ # --video-scale-x=1 into --video-scale-x=yes, which mpv rejects.
65
+ # Fixed in 8ddcfec; pinned here because the bug is invisible until
66
+ # mpv refuses the option.
67
+ argv = build_argv(video_scale_x=1, volume=0)
68
+ self.assertIn("--video-scale-x=1", argv)
69
+ self.assertIn("--volume=0", argv)
70
+
71
+ def test_floats_and_strings_pass_through_as_written(self):
72
+ argv = build_argv(speed=1.5, title="a movie")
73
+ self.assertIn("--speed=1.5", argv)
74
+ self.assertIn("--title=a movie", argv)
75
+
76
+ def test_a_list_becomes_a_repeated_flag(self):
77
+ argv = build_argv(script=["a.lua", "b.lua"])
78
+ self.assertEqual([a for a in argv if a.startswith("--script=")],
79
+ ["--script=a.lua", "--script=b.lua"])
80
+
81
+ def test_the_caller_can_override_every_default(self):
82
+ argv = build_argv(idle=False, terminal=True, input_terminal=True)
83
+ self.assertIn("--idle=no", argv)
84
+ self.assertIn("--terminal=yes", argv)
85
+ self.assertIn("--input-terminal=yes", argv)
86
+ self.assertEqual(len([a for a in argv if a.startswith("--idle=")]), 1)
87
+
88
+ def test_the_ipc_server_option_is_not_overridable_by_that_name(self):
89
+ # input_ipc_server is a default like any other, so a caller CAN
90
+ # replace it -- and then the library connects to the socket it was
91
+ # told about rather than the one mpv opens. Pinned as-is: it is a
92
+ # foot-gun, but changing it would change behaviour.
93
+ argv = build_argv(input_ipc_server="/tmp/elsewhere")
94
+ self.assertIn("--input-ipc-server=/tmp/elsewhere", argv)
95
+ self.assertNotIn("--input-ipc-server=/tmp/testsocket", argv)
96
+
97
+
98
+ class SocketPathTest(unittest.TestCase):
99
+ @unittest.skipIf(os.name == "nt", "POSIX socket paths only")
100
+ def test_a_stale_socket_file_is_removed_before_starting(self):
101
+ import tempfile
102
+ path = os.path.join(tempfile.mkdtemp(), "stale.sock")
103
+ with open(path, "w") as handle:
104
+ handle.write("")
105
+ self.assertTrue(os.path.exists(path))
106
+ build_argv(ipc_socket=path)
107
+ self.assertFalse(os.path.exists(path))
108
+
109
+
110
+ class MpvFmtTest(unittest.TestCase):
111
+ """`_mpv_fmt` in isolation, since it is where the bool/int trap lives."""
112
+
113
+ def setUp(self):
114
+ self.fmt = python_mpv_jsonipc.MPVProcess._mpv_fmt
115
+
116
+ def test_identity_comparison_keeps_numbers_intact(self):
117
+ self.assertEqual(self.fmt(None, True), "yes")
118
+ self.assertEqual(self.fmt(None, False), "no")
119
+ self.assertEqual(self.fmt(None, 1), 1)
120
+ self.assertEqual(self.fmt(None, 0), 0)
121
+ self.assertEqual(self.fmt(None, "yes"), "yes")
122
+
123
+
124
+ if __name__ == "__main__":
125
+ unittest.main()
@@ -0,0 +1,189 @@
1
+ """Properties and commands against a real mpv.
2
+
3
+ Everything here needs a live mpv on purpose. The dynamic attribute machinery
4
+ is built from what mpv answers at construction time -- `property-list` and
5
+ `command-list` -- so a stand-in that returns a convenient list would be
6
+ testing the list, not the library.
7
+ """
8
+
9
+ import unittest
10
+
11
+ import os
12
+
13
+ from _harness import (LiveMPVTest, MPV_BINARY,
14
+ python_mpv_jsonipc)
15
+
16
+
17
+ class ConstructionTest(LiveMPVTest):
18
+ def test_properties_are_discovered_and_underscored(self):
19
+ self.assertIn("pause", self.mpv.properties)
20
+ self.assertIn("playback_time", self.mpv.properties) # playback-time
21
+ self.assertNotIn("playback-time", self.mpv.properties)
22
+
23
+ def test_commands_are_attached_as_methods(self):
24
+ for command in ("loadfile", "seek", "quit", "keybind"):
25
+ self.assertTrue(callable(getattr(self.mpv, command, None)),
26
+ "{0} is not callable".format(command))
27
+
28
+ def test_json_ipc_only_commands_are_not_attached(self):
29
+ # These are not in mpv's `command-list` -- they exist only over the
30
+ # IPC transport -- so they never become methods. Callers reach them
31
+ # through `command()`, or through the wrappers that exist for the
32
+ # two that have one (`bind_property_observer`, `wait_for_property`).
33
+ # Worth pinning: it looks like an omission and is the actual design.
34
+ for command in ("observe_property", "unobserve_property",
35
+ "get_property", "set_property",
36
+ "request_log_messages", "client_name"):
37
+ self.assertFalse(callable(getattr(self.mpv, command, None)),
38
+ "{0} unexpectedly became a method".format(command))
39
+
40
+ def test_a_command_that_collides_with_a_property_gets_a_cmd_suffix(self):
41
+ # `af` and `vf` are both, on every mpv this has been run against.
42
+ # Discovered rather than hard-coded so a change in mpv shows up as a
43
+ # skip, not a spurious failure.
44
+ collisions = [name for name in ("af", "vf")
45
+ if name in self.mpv.properties]
46
+ if not collisions:
47
+ self.skipTest("this mpv has no command/property name collisions")
48
+ for name in collisions:
49
+ self.assertTrue(callable(getattr(self.mpv, name + "_cmd", None)))
50
+
51
+ def test_dir_lists_properties_alongside_real_attributes(self):
52
+ listing = dir(self.mpv)
53
+ self.assertIn("pause", listing)
54
+ self.assertIn("terminate", listing)
55
+
56
+
57
+ class PropertyAccessTest(LiveMPVTest):
58
+ def test_a_property_round_trips_through_attribute_access(self):
59
+ self.mpv.pause = True
60
+ self.assertIs(self.mpv.pause, True)
61
+ self.mpv.pause = False
62
+ self.assertIs(self.mpv.pause, False)
63
+
64
+ def test_an_unavailable_property_reads_as_none_rather_than_raising(self):
65
+ # Nothing is loaded, so mpv answers "property unavailable". The
66
+ # library turns exactly that one error into None; downstream code
67
+ # leans on it heavily (`if mpv.width:`).
68
+ self.assertIsNone(self.mpv.width)
69
+
70
+ def test_an_unknown_property_raises_mpv_error(self):
71
+ with self.assertRaises(python_mpv_jsonipc.MPVError) as caught:
72
+ self.mpv.command("get_property", "no-such-property")
73
+ self.assertEqual(str(caught.exception), "property not found")
74
+
75
+ def test_an_unknown_attribute_is_a_normal_attribute_error(self):
76
+ # Not an MPVError: `properties` gates the property path, so anything
77
+ # else falls through to ordinary attribute lookup.
78
+ with self.assertRaises(AttributeError):
79
+ self.mpv.definitely_not_a_property
80
+
81
+ def test_setting_a_non_property_attribute_stays_local(self):
82
+ self.mpv.my_own_bookkeeping = 42
83
+ self.assertEqual(self.mpv.my_own_bookkeeping, 42)
84
+
85
+
86
+ class CommandTest(LiveMPVTest):
87
+ def test_command_returns_the_data_field(self):
88
+ self.assertIsInstance(self.mpv.command("get_property", "mpv-version"),
89
+ str)
90
+
91
+ def test_play_is_an_alias_for_loadfile(self):
92
+ seen = []
93
+ self.mpv.loadfile = lambda *args: seen.append(args)
94
+ self.mpv.play("av://lavfi:testsrc")
95
+ self.assertEqual(seen, [("av://lavfi:testsrc",)])
96
+
97
+
98
+ class AttachToExistingMPVTest(unittest.TestCase):
99
+ """`start_mpv=False` -- attaching to an mpv somebody else started.
100
+
101
+ jellyfin-mpv-shim's `mpv_ext_start: false` runs this way, and it is the
102
+ path with the least coverage elsewhere: no `MPVProcess` is built, so
103
+ `_ipc_endpoint_ready` never runs and the transport's own connect retry is
104
+ the only thing that waits for the endpoint.
105
+ """
106
+
107
+ @classmethod
108
+ def setUpClass(cls):
109
+ if MPV_BINARY is None:
110
+ raise unittest.SkipTest("no mpv binary found")
111
+
112
+ def setUp(self):
113
+ import random
114
+ import subprocess
115
+ import time
116
+
117
+ name = "mpvtest{0}".format(random.randint(0, 2 ** 48))
118
+ # Windows wants a bare pipe name (the library prepends the
119
+ # \\.\pipe\ prefix); POSIX wants a filesystem path.
120
+ self.ipc_socket = name if os.name == "nt" else "/tmp/" + name
121
+
122
+ self.process = subprocess.Popen([
123
+ MPV_BINARY, "--idle=yes", "--config=no", "--vo=null", "--ao=null",
124
+ "--terminal=no", "--input-ipc-server=" + self.ipc_socket])
125
+ self.addCleanup(self._stop_mpv)
126
+
127
+ deadline = time.time() + 10
128
+ while time.time() < deadline:
129
+ if python_mpv_jsonipc._ipc_endpoint_ready(
130
+ self.ipc_socket if os.name != "nt"
131
+ else "\\\\.\\pipe\\" + self.ipc_socket):
132
+ break
133
+ time.sleep(0.1)
134
+ else:
135
+ self.fail("mpv never opened its IPC endpoint")
136
+
137
+ def _stop_mpv(self):
138
+ self.process.terminate()
139
+ self.process.wait(timeout=10)
140
+ if os.name != "nt" and os.path.exists(self.ipc_socket):
141
+ os.remove(self.ipc_socket)
142
+
143
+ def test_it_attaches_and_talks_to_the_running_mpv(self):
144
+ mpv = python_mpv_jsonipc.MPV(start_mpv=False,
145
+ ipc_socket=self.ipc_socket)
146
+ self.addCleanup(mpv.terminate)
147
+ self.assertIsInstance(mpv.command("get_property", "mpv-version"), str)
148
+ mpv.pause = True
149
+ self.assertIs(mpv.pause, True)
150
+
151
+ def test_terminate_leaves_the_process_it_did_not_start_alone(self):
152
+ # It never owned the process, so it must not kill it. A host that
153
+ # attaches to the user's own mpv would otherwise shut it down.
154
+ mpv = python_mpv_jsonipc.MPV(start_mpv=False,
155
+ ipc_socket=self.ipc_socket)
156
+ self.assertIsNone(mpv.mpv_process)
157
+ mpv.terminate()
158
+ self.assertIsNone(self.process.poll(),
159
+ "terminate() killed an mpv it did not start")
160
+
161
+
162
+ class TeardownTest(LiveMPVTest):
163
+ def test_terminate_is_safe_to_call_twice(self):
164
+ # __del__ calls terminate() too, so every user of this library
165
+ # terminates at least twice whether they meant to or not.
166
+ self.mpv.terminate()
167
+ self.mpv.terminate()
168
+
169
+ @unittest.skipIf(__import__("os").name == "nt", "POSIX socket files only")
170
+ def test_terminate_removes_the_socket_file_it_created(self):
171
+ # A long-running host that opens and closes many mpv instances would
172
+ # otherwise fill /tmp with dead sockets.
173
+ import glob
174
+ import os
175
+ socket_path = self.mpv.mpv_process.ipc_socket
176
+ self.assertTrue(os.path.exists(socket_path))
177
+ self.mpv.terminate()
178
+ self.assertFalse(os.path.exists(socket_path))
179
+
180
+ def test_commands_after_terminate_fail_fast_rather_than_hanging(self):
181
+ # The 120s command timeout would be a very long way to discover a
182
+ # closed socket. The transport raises instead.
183
+ self.mpv.terminate()
184
+ with self.assertRaises(BrokenPipeError):
185
+ self.mpv.command("get_property", "pause")
186
+
187
+
188
+ if __name__ == "__main__":
189
+ unittest.main()
@@ -0,0 +1,177 @@
1
+ """Observers, events, key bindings and log forwarding, against a real mpv.
2
+
3
+ Every callback here is delivered on the `EventHandler` thread, so each test
4
+ waits on a `threading.Event` with a bounded timeout rather than sleeping. A
5
+ test that sleeps a fixed amount either wastes the time or fails on a loaded
6
+ CI runner, and usually both.
7
+ """
8
+
9
+ import threading
10
+ import unittest
11
+
12
+ from _harness import (LIVE_OPTIONS, LiveMPVTest, MPV_BINARY,
13
+ python_mpv_jsonipc)
14
+
15
+ TIMEOUT = 10
16
+
17
+
18
+ def waiter():
19
+ """An event plus a recorder, for asserting on an async callback."""
20
+ seen = []
21
+ fired = threading.Event()
22
+
23
+ def record(*args):
24
+ seen.append(args)
25
+ fired.set()
26
+
27
+ return seen, fired, record
28
+
29
+
30
+ class PropertyObserverTest(LiveMPVTest):
31
+ def test_observer_ids_start_at_one_and_increment(self):
32
+ first = self.mpv.bind_property_observer("pause", lambda *_: None)
33
+ second = self.mpv.bind_property_observer("volume", lambda *_: None)
34
+ self.assertEqual(first, 1)
35
+ self.assertEqual(second, 2)
36
+
37
+ def test_an_observer_is_called_with_the_name_and_value(self):
38
+ seen, fired, record = waiter()
39
+ self.mpv.bind_property_observer("pause", record)
40
+ # mpv sends the current value immediately on observe, before any
41
+ # change -- that first notification is what `wait_for_property`
42
+ # exists to skip.
43
+ self.assertTrue(fired.wait(TIMEOUT), "observer never fired")
44
+ self.assertEqual(seen[0][0], "pause")
45
+
46
+ def test_an_observer_sees_a_change(self):
47
+ seen, fired, record = waiter()
48
+ self.mpv.bind_property_observer("pause", record)
49
+ self.assertTrue(fired.wait(TIMEOUT))
50
+ fired.clear()
51
+ self.mpv.pause = True
52
+ self.assertTrue(fired.wait(TIMEOUT), "no notification for the change")
53
+ self.assertEqual(seen[-1], ("pause", True))
54
+
55
+ def test_unbinding_stops_the_callbacks_and_forgets_the_binding(self):
56
+ observer_id = self.mpv.bind_property_observer("pause", lambda *_: None)
57
+ self.assertIn(observer_id, self.mpv.property_bindings)
58
+ self.mpv.unbind_property_observer(observer_id)
59
+ self.assertNotIn(observer_id, self.mpv.property_bindings)
60
+
61
+ def test_property_observer_decorator_binds(self):
62
+ seen, fired, record = waiter()
63
+
64
+ @self.mpv.property_observer("pause")
65
+ def _handler(name, value):
66
+ record(name, value)
67
+
68
+ self.assertTrue(fired.wait(TIMEOUT))
69
+
70
+
71
+ class WaitForPropertyTest(LiveMPVTest):
72
+ def test_it_returns_only_after_an_actual_change(self):
73
+ # The first notification is mpv reporting the current value. If
74
+ # wait_for_property honoured it, this would return before `pause`
75
+ # was ever set and the assertion below would be a coin flip.
76
+ done = threading.Event()
77
+
78
+ def wait():
79
+ self.mpv.wait_for_property("pause")
80
+ done.set()
81
+
82
+ thread = threading.Thread(target=wait, daemon=True)
83
+ thread.start()
84
+ self.assertFalse(done.wait(1.0),
85
+ "returned on the initial value notification")
86
+ self.mpv.pause = True
87
+ self.assertTrue(done.wait(TIMEOUT), "never returned after the change")
88
+
89
+
90
+ class EventTest(LiveMPVTest):
91
+ def test_on_event_receives_mpv_events(self):
92
+ seen, fired, record = waiter()
93
+ self.mpv.on_event("client-message")(record)
94
+ self.mpv.command("script-message", "hello")
95
+ self.assertTrue(fired.wait(TIMEOUT), "no client-message event")
96
+ self.assertEqual(seen[-1][0]["args"], ["hello"])
97
+
98
+ def test_event_callback_is_an_alias_of_on_event(self):
99
+ # Kept for python-mpv compatibility; downstream uses both spellings.
100
+ seen, fired, record = waiter()
101
+ self.mpv.event_callback("client-message")(record)
102
+ self.mpv.command("script-message", "via-alias")
103
+ self.assertTrue(fired.wait(TIMEOUT))
104
+
105
+ def test_bind_event_allows_several_callbacks_for_one_event(self):
106
+ first, fired_first, record_first = waiter()
107
+ second, fired_second, record_second = waiter()
108
+ self.mpv.bind_event("client-message", record_first)
109
+ self.mpv.bind_event("client-message", record_second)
110
+ self.mpv.command("script-message", "both")
111
+ self.assertTrue(fired_first.wait(TIMEOUT))
112
+ self.assertTrue(fired_second.wait(TIMEOUT))
113
+
114
+
115
+ class KeyBindingTest(LiveMPVTest):
116
+ def test_a_bound_key_fires_its_callback(self):
117
+ # Round trips through mpv: keybind -> script-message custom-bind
118
+ # <id> -> client-message -> the callback registry.
119
+ seen, fired, record = waiter()
120
+ self.mpv.bind_key_press("a", record)
121
+ self.mpv.command("keypress", "a")
122
+ self.assertTrue(fired.wait(TIMEOUT), "key binding never fired")
123
+
124
+ def test_on_key_press_decorator_binds(self):
125
+ seen, fired, record = waiter()
126
+
127
+ @self.mpv.on_key_press("b")
128
+ def _handler():
129
+ record()
130
+
131
+ self.mpv.command("keypress", "b")
132
+ self.assertTrue(fired.wait(TIMEOUT))
133
+
134
+
135
+ class LogHandlerTest(unittest.TestCase):
136
+ """The one existing opt-in output channel. Off unless BOTH log_handler
137
+ and loglevel are given -- pinned because the new startup-failure
138
+ diagnosis should follow this shape rather than invent another."""
139
+
140
+ def _mpv(self, **extra):
141
+ options = dict(LIVE_OPTIONS)
142
+ options.update(extra)
143
+ instance = python_mpv_jsonipc.MPV(mpv_location=MPV_BINARY, **options)
144
+ self.addCleanup(instance.terminate)
145
+ return instance
146
+
147
+ @classmethod
148
+ def setUpClass(cls):
149
+ if MPV_BINARY is None:
150
+ raise unittest.SkipTest("no mpv binary found")
151
+
152
+ def test_a_handler_with_a_level_receives_log_messages(self):
153
+ # `debug`, not `info`: an idle mpv says nothing at info or v once it
154
+ # has finished starting, and `request_log_messages` only forwards
155
+ # what is emitted after the request. Measured -- at info and v this
156
+ # test waits the full timeout and sees zero messages.
157
+ seen, fired, record = waiter()
158
+ mpv = self._mpv(log_handler=record, loglevel="debug")
159
+ mpv.command("script-message", "provoke-some-logging")
160
+ mpv.command("get_property", "mpv-version")
161
+ self.assertTrue(fired.wait(TIMEOUT), "no log messages arrived")
162
+ level, prefix, text = seen[0]
163
+ self.assertIsInstance(level, str)
164
+ self.assertIsInstance(prefix, str)
165
+ self.assertIsInstance(text, str)
166
+
167
+ def test_a_handler_without_a_level_is_ignored(self):
168
+ seen, fired, record = waiter()
169
+ mpv = self._mpv(log_handler=record)
170
+ mpv.command("script-message", "provoke-some-logging")
171
+ mpv.command("get_property", "mpv-version")
172
+ self.assertFalse(fired.wait(1.0),
173
+ "log messages arrived without a loglevel")
174
+
175
+
176
+ if __name__ == "__main__":
177
+ unittest.main()
@@ -0,0 +1,240 @@
1
+ """What happens when mpv will not start.
2
+
3
+ **Most of this file exists to be changed.** The error handling here is due to
4
+ be replaced with something that names the option mpv rejected and refuses to
5
+ retry a start that cannot succeed. Pinning the current behaviour first is what
6
+ makes that change reviewable: the diff will show precisely which observable
7
+ things moved, rather than leaving it to be discovered by a downstream user on
8
+ an unmaintained project.
9
+
10
+ So: assert what it does, not what it should do. In particular the exact
11
+ exception message and the retry count are load-bearing here only because
12
+ somebody out there may be matching on them.
13
+ """
14
+
15
+ import os
16
+ import subprocess
17
+ import time
18
+ import unittest
19
+ from unittest import mock
20
+
21
+ from _harness import (LIVE_OPTIONS, MPV_BINARY, python_mpv_jsonipc,
22
+ requires_mpv)
23
+
24
+
25
+ @requires_mpv
26
+ class BadOptionTest(unittest.TestCase):
27
+ """mpv exits immediately when handed an option it does not have. This is
28
+ the exact case the shim hits with a build-gated option such as
29
+ `--input-gamepad` on an mpv built without SDL2 gamepad support.
30
+
31
+ **These assertions were rewritten deliberately** when start-failure
32
+ diagnosis landed. What they said before is preserved in
33
+ `PreDiagnosisBehaviourTest` below, reachable with
34
+ `diagnose_start_failures=False`, because that is the behaviour downstream
35
+ projects have been living with.
36
+ """
37
+
38
+ def start_with_bad_option(self, **overrides):
39
+ options = dict(LIVE_OPTIONS)
40
+ options.update(dict(mpv_location=MPV_BINARY,
41
+ start_retries=3,
42
+ start_retry_delay_ms=50))
43
+ options.update(overrides)
44
+ options["definitely_not_an_mpv_option"] = "yes"
45
+ return python_mpv_jsonipc.MPV(**options)
46
+
47
+ def test_the_error_names_the_option_mpv_refused(self):
48
+ with self.assertRaises(python_mpv_jsonipc.MPVProcessError) as caught:
49
+ self.start_with_bad_option()
50
+ self.assertEqual(caught.exception.bad_option,
51
+ "definitely-not-an-mpv-option")
52
+ self.assertIn("definitely-not-an-mpv-option", str(caught.exception))
53
+
54
+ def test_it_is_still_an_mpv_error(self):
55
+ # The compatibility promise. Downstream catches MPVError and nothing
56
+ # finer; a new type that escaped those handlers would be a break
57
+ # dressed up as an improvement.
58
+ with self.assertRaises(python_mpv_jsonipc.MPVError):
59
+ self.start_with_bad_option()
60
+
61
+ def test_it_carries_mpvs_exit_status_and_its_own_words(self):
62
+ with self.assertRaises(python_mpv_jsonipc.MPVProcessError) as caught:
63
+ self.start_with_bad_option()
64
+ self.assertEqual(caught.exception.returncode, 1)
65
+ self.assertIn("Error parsing option", caught.exception.log_output)
66
+ self.assertFalse(caught.exception.retryable)
67
+
68
+ def test_it_stops_instead_of_spending_every_retry(self):
69
+ # Exactly one real start attempt plus one diagnostic probe, and no
70
+ # second attempt. Counted by shape rather than by total, because the
71
+ # probe goes through Popen too (subprocess.run uses it), so a bare
72
+ # count of 2 would also be satisfied by two starts and no probe.
73
+ with mock.patch.object(python_mpv_jsonipc.subprocess, "Popen",
74
+ wraps=subprocess.Popen) as popen:
75
+ with self.assertRaises(python_mpv_jsonipc.MPVProcessError):
76
+ self.start_with_bad_option(start_retries=3)
77
+ argvs = [call.args[0] for call in popen.call_args_list]
78
+ starts = [a for a in argvs if "--version" not in a]
79
+ probes = [a for a in argvs if "--version" in a]
80
+ self.assertEqual(len(starts), 1, "MPV was started more than once")
81
+ self.assertEqual(len(probes), 1, "the diagnosis ran more than once")
82
+
83
+ def test_it_does_not_pay_the_retry_delay_it_cannot_use(self):
84
+ started = time.perf_counter()
85
+ with self.assertRaises(python_mpv_jsonipc.MPVProcessError):
86
+ self.start_with_bad_option(start_retries=3,
87
+ start_retry_delay_ms=3000)
88
+ self.assertLess(time.perf_counter() - started, 3.0)
89
+
90
+ def test_the_diagnosis_does_not_bind_the_ipc_socket(self):
91
+ # The probe re-runs MPV with the same --input-ipc-server. `--version`
92
+ # makes it exit before serving, but if that ever stopped being true
93
+ # the diagnosis would squat on the endpoint the real MPV is about to
94
+ # open -- turning a diagnosable failure into a mysterious one.
95
+ socket_path = "/tmp/diagnosis-probe-{0}".format(os.getpid())
96
+ if os.name == "nt":
97
+ self.skipTest("POSIX socket paths only")
98
+ with self.assertRaises(python_mpv_jsonipc.MPVProcessError):
99
+ self.start_with_bad_option(ipc_socket=socket_path)
100
+ self.assertFalse(os.path.exists(socket_path))
101
+
102
+ def test_mpvs_own_words_survive_even_when_no_option_is_named(self):
103
+ # Not every refusal is an option error -- a missing DLL or an
104
+ # unusable VO reads the same way to a user: something changed and
105
+ # there is nothing to go on. On Windows especially there is no
106
+ # console they could have seen it in. So whatever MPV said is
107
+ # attached even when the diagnosis cannot name an option.
108
+ with mock.patch.object(python_mpv_jsonipc, "_diagnose_start_failure",
109
+ return_value=(None, "mpv: some other refusal")):
110
+ with self.assertRaises(python_mpv_jsonipc.MPVProcessError) as caught:
111
+ self.start_with_bad_option(start_retries=2)
112
+ self.assertEqual(str(caught.exception),
113
+ "MPV process retry limit reached.")
114
+ self.assertEqual(caught.exception.log_output, "mpv: some other refusal")
115
+
116
+ def test_a_failure_it_cannot_explain_still_retries_and_reports_as_before(self):
117
+ # The diagnosis is not always conclusive -- mpv may have died for a
118
+ # reason it did not write down. That path must behave exactly as it
119
+ # always did, or an unexplained flake becomes a hard failure.
120
+ with mock.patch.object(python_mpv_jsonipc, "_diagnose_start_failure",
121
+ return_value=(None, "nothing conclusive")):
122
+ with mock.patch.object(python_mpv_jsonipc.subprocess, "Popen",
123
+ wraps=subprocess.Popen) as popen:
124
+ with self.assertRaises(python_mpv_jsonipc.MPVError) as caught:
125
+ self.start_with_bad_option(start_retries=3)
126
+ self.assertEqual(str(caught.exception),
127
+ "MPV process retry limit reached.")
128
+ self.assertEqual(popen.call_count, 3)
129
+
130
+
131
+ @requires_mpv
132
+ class PreDiagnosisBehaviourTest(unittest.TestCase):
133
+ """`diagnose_start_failures=False` -- what every release before 1.3 did.
134
+
135
+ Kept as a supported escape hatch, and tested, because "you can turn the
136
+ new behaviour off" is worth nothing if nobody checks that the switch
137
+ still reaches the old code.
138
+ """
139
+
140
+ def start_with_bad_option(self, **overrides):
141
+ options = dict(LIVE_OPTIONS)
142
+ options.update(dict(mpv_location=MPV_BINARY,
143
+ start_retries=3,
144
+ start_retry_delay_ms=50,
145
+ diagnose_start_failures=False))
146
+ options.update(overrides)
147
+ options["definitely_not_an_mpv_option"] = "yes"
148
+ return python_mpv_jsonipc.MPV(**options)
149
+
150
+ def test_it_raises_the_retry_limit_message_and_names_no_option(self):
151
+ with self.assertRaises(python_mpv_jsonipc.MPVError) as caught:
152
+ self.start_with_bad_option()
153
+ self.assertEqual(str(caught.exception),
154
+ "MPV process retry limit reached.")
155
+ self.assertNotIn("definitely", str(caught.exception))
156
+
157
+ def test_every_retry_is_spent_on_a_start_that_cannot_succeed(self):
158
+ with mock.patch.object(python_mpv_jsonipc.subprocess, "Popen",
159
+ wraps=subprocess.Popen) as popen:
160
+ with self.assertRaises(python_mpv_jsonipc.MPVError):
161
+ self.start_with_bad_option(start_retries=3)
162
+ self.assertEqual(popen.call_count, 3)
163
+
164
+ def test_the_configured_delay_is_paid_after_every_failure(self):
165
+ started = time.perf_counter()
166
+ with self.assertRaises(python_mpv_jsonipc.MPVError):
167
+ self.start_with_bad_option(start_retries=3,
168
+ start_retry_delay_ms=300)
169
+ self.assertGreaterEqual(time.perf_counter() - started, 0.9)
170
+
171
+
172
+ @requires_mpv
173
+ class MissingBinaryTest(unittest.TestCase):
174
+ def test_a_missing_mpv_binary_is_not_retried_and_is_not_an_mpv_error(self):
175
+ # `except MPVError` in the retry loop does not catch this, so it
176
+ # escapes on the first attempt. jellyfin-mpv-shim depends on exactly
177
+ # this to tell "no mpv installed" from "mpv rejected an option".
178
+ with mock.patch.object(python_mpv_jsonipc.subprocess, "Popen",
179
+ wraps=subprocess.Popen) as popen:
180
+ with self.assertRaises(FileNotFoundError):
181
+ python_mpv_jsonipc.MPV(
182
+ mpv_location=os.path.join(os.sep, "nonexistent", "mpv"),
183
+ start_retries=5, start_retry_delay_ms=50, **LIVE_OPTIONS)
184
+ self.assertEqual(popen.call_count, 1)
185
+
186
+
187
+ @requires_mpv
188
+ class NoServerTest(unittest.TestCase):
189
+ def test_attaching_to_a_socket_nothing_is_listening_on_raises(self):
190
+ # start_mpv=False means "something else owns the process". If the
191
+ # endpoint is not there, the transport raises out of the constructor
192
+ # rather than waiting -- but *which* exception is a real divergence
193
+ # between the two transports, found by the Windows CI leg rather than
194
+ # by reading the code:
195
+ #
196
+ # UnixSocket -> socket.connect fails -> OSError, immediately.
197
+ # WindowsSocket -> CreateFile fails -> five attempts a second apart
198
+ # -> MPVError("Cannot connect to pipe."), which is
199
+ # NOT an OSError.
200
+ #
201
+ # Pinned per platform rather than widened to `Exception`, because the
202
+ # difference is the thing worth knowing: code that catches OSError
203
+ # around this constructor works on Linux and not on Windows.
204
+ expected = (python_mpv_jsonipc.MPVError if os.name == "nt"
205
+ else OSError)
206
+ with self.assertRaises(expected):
207
+ python_mpv_jsonipc.MPV(start_mpv=False,
208
+ ipc_socket="definitely-no-mpv-here"
209
+ if os.name == "nt"
210
+ else "/tmp/definitely-no-mpv-here")
211
+
212
+
213
+ @requires_mpv
214
+ class QuitCallbackTest(unittest.TestCase):
215
+ def test_it_fires_when_mpv_goes_away(self):
216
+ import threading
217
+ fired = threading.Event()
218
+ mpv = python_mpv_jsonipc.MPV(mpv_location=MPV_BINARY,
219
+ quit_callback=fired.set, **LIVE_OPTIONS)
220
+ self.addCleanup(lambda: None)
221
+ try:
222
+ mpv.command("quit")
223
+ except Exception:
224
+ pass # the socket may die before the reply arrives
225
+ self.assertTrue(fired.wait(10), "quit_callback never fired")
226
+
227
+
228
+ @requires_mpv
229
+ class SuccessfulStartTest(unittest.TestCase):
230
+ def test_a_good_start_spawns_exactly_one_process(self):
231
+ with mock.patch.object(python_mpv_jsonipc.subprocess, "Popen",
232
+ wraps=subprocess.Popen) as popen:
233
+ mpv = python_mpv_jsonipc.MPV(mpv_location=MPV_BINARY,
234
+ **LIVE_OPTIONS)
235
+ self.addCleanup(mpv.terminate)
236
+ self.assertEqual(popen.call_count, 1)
237
+
238
+
239
+ if __name__ == "__main__":
240
+ unittest.main()
@@ -0,0 +1,104 @@
1
+ """The module must stay parseable by the Python it claims to support.
2
+
3
+ `pyproject.toml` says `requires-python = ">=3.6"`, and pip believes it: a
4
+ stray f-string with `=`, a walrus, or `match` would install cleanly on an old
5
+ interpreter and then fail at import, on a machine no CI here can reach.
6
+
7
+ Raising the floor is itself a downstream break -- pip refuses to install on
8
+ anything below it -- so the floor is treated as fixed and the syntax is what
9
+ gets checked. The test reads the version from `pyproject.toml` rather than
10
+ hard-coding it, so lowering or raising the claim moves the check with it.
11
+
12
+ Only the shipped module is checked. The tests themselves are free to use
13
+ whatever the CI interpreters provide.
14
+ """
15
+
16
+ import ast
17
+ import os
18
+ import re
19
+ import unittest
20
+
21
+ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
22
+ MODULE = os.path.join(ROOT, "python_mpv_jsonipc.py")
23
+ PYPROJECT = os.path.join(ROOT, "pyproject.toml")
24
+
25
+
26
+ def declared_floor():
27
+ with open(PYPROJECT) as handle:
28
+ match = re.search(r'requires-python\s*=\s*"[><=~^]*\s*(\d+)\.(\d+)',
29
+ handle.read())
30
+ if not match:
31
+ raise AssertionError("no requires-python in pyproject.toml")
32
+ return int(match.group(1)), int(match.group(2))
33
+
34
+
35
+ #: Syntax that does not exist before the stated version, for probing whether
36
+ #: this interpreter enforces `feature_version` at all.
37
+ PROBES = (
38
+ ((3, 8), "if (n := 1): pass"),
39
+ ((3, 10), "match 1:\n case 1: pass\n"),
40
+ )
41
+
42
+
43
+ def enforcement_probe(floor):
44
+ """A snippet newer than *floor*, or None if we have nothing newer."""
45
+ for version, source in PROBES:
46
+ if version > floor:
47
+ return source
48
+ return None
49
+
50
+
51
+ def enforces_feature_version(floor):
52
+ """Whether `ast.parse` on THIS interpreter rejects newer syntax.
53
+
54
+ Not a version comparison: CPython's enforcement has been tightened over
55
+ time, and 3.9 accepts a walrus at `feature_version=(3, 6)` where 3.13
56
+ rejects it. Asking the interpreter is the only reliable form of the
57
+ question -- discovered when this file's own meta-test failed on the 3.9
58
+ CI leg and nowhere else.
59
+ """
60
+ source = enforcement_probe(floor)
61
+ if source is None:
62
+ return False
63
+ try:
64
+ ast.parse(source, feature_version=floor)
65
+ except SyntaxError:
66
+ return True
67
+ return False
68
+
69
+
70
+ class PythonFloorTest(unittest.TestCase):
71
+ def test_the_module_parses_as_the_oldest_supported_python(self):
72
+ floor = declared_floor()
73
+ if not enforces_feature_version(floor):
74
+ # Honest skip rather than a pass that proves nothing. The guard
75
+ # only has teeth on interpreters that enforce feature_version,
76
+ # and the CI matrix includes several that do.
77
+ self.skipTest(
78
+ "this interpreter does not enforce feature_version={0}; "
79
+ "the check is meaningless here".format(floor))
80
+ with open(MODULE) as handle:
81
+ source = handle.read()
82
+ try:
83
+ ast.parse(source, filename=MODULE, feature_version=floor)
84
+ except SyntaxError as error:
85
+ self.fail(
86
+ "python_mpv_jsonipc.py uses syntax newer than the declared "
87
+ "floor {0}.{1}: {2} (line {3}). Either rewrite it or change "
88
+ "requires-python -- but note that raising the floor stops "
89
+ "pip installing for everyone below it.".format(
90
+ floor[0], floor[1], error.msg, error.lineno))
91
+
92
+ def test_at_least_the_mechanism_is_exercised_somewhere(self):
93
+ # The check above passes trivially if `feature_version` is ignored,
94
+ # which is the shape of a test that cannot fail. This asserts the
95
+ # rejection really happens wherever it is claimed to.
96
+ floor = declared_floor()
97
+ if not enforces_feature_version(floor):
98
+ self.skipTest("no enforcement on this interpreter")
99
+ with self.assertRaises(SyntaxError):
100
+ ast.parse(enforcement_probe(floor), feature_version=floor)
101
+
102
+
103
+ if __name__ == "__main__":
104
+ unittest.main()
@@ -1,8 +0,0 @@
1
- LICENSE.md
2
- README.md
3
- pyproject.toml
4
- python_mpv_jsonipc.py
5
- python_mpv_jsonipc.egg-info/PKG-INFO
6
- python_mpv_jsonipc.egg-info/SOURCES.txt
7
- python_mpv_jsonipc.egg-info/dependency_links.txt
8
- python_mpv_jsonipc.egg-info/top_level.txt