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.
- {python_mpv_jsonipc-1.2.3/python_mpv_jsonipc.egg-info → python_mpv_jsonipc-1.3.0}/PKG-INFO +1 -1
- {python_mpv_jsonipc-1.2.3 → python_mpv_jsonipc-1.3.0}/pyproject.toml +1 -1
- {python_mpv_jsonipc-1.2.3 → python_mpv_jsonipc-1.3.0/python_mpv_jsonipc.egg-info}/PKG-INFO +1 -1
- python_mpv_jsonipc-1.3.0/python_mpv_jsonipc.egg-info/SOURCES.txt +14 -0
- {python_mpv_jsonipc-1.2.3 → python_mpv_jsonipc-1.3.0}/python_mpv_jsonipc.py +151 -6
- python_mpv_jsonipc-1.3.0/tests/test_api_surface.py +136 -0
- python_mpv_jsonipc-1.3.0/tests/test_arg_building.py +125 -0
- python_mpv_jsonipc-1.3.0/tests/test_live_basic.py +189 -0
- python_mpv_jsonipc-1.3.0/tests/test_live_events.py +177 -0
- python_mpv_jsonipc-1.3.0/tests/test_live_failure.py +240 -0
- python_mpv_jsonipc-1.3.0/tests/test_python_floor.py +104 -0
- python_mpv_jsonipc-1.2.3/python_mpv_jsonipc.egg-info/SOURCES.txt +0 -8
- {python_mpv_jsonipc-1.2.3 → python_mpv_jsonipc-1.3.0}/LICENSE.md +0 -0
- {python_mpv_jsonipc-1.2.3 → python_mpv_jsonipc-1.3.0}/README.md +0 -0
- {python_mpv_jsonipc-1.2.3 → python_mpv_jsonipc-1.3.0}/python_mpv_jsonipc.egg-info/dependency_links.txt +0 -0
- {python_mpv_jsonipc-1.2.3 → python_mpv_jsonipc-1.3.0}/python_mpv_jsonipc.egg-info/top_level.txt +0 -0
- {python_mpv_jsonipc-1.2.3 → python_mpv_jsonipc-1.3.0}/setup.cfg +0 -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
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_mpv_jsonipc-1.2.3 → python_mpv_jsonipc-1.3.0}/python_mpv_jsonipc.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|