Repository: streamlink/streamlink
Pull request #3639 (2021-03-21 11:21:29)

cli: debug-log arguments set by the user
We've had this discussion a while ago and I think we should log all arguments set by the user while debug logging, not just plugin specific arguments.

- The first commit therefore replaces the "Plugin specific arguments" logging with the new `streamlink_cli.main.log_current_arguments` method which logs all arguments set by the user, on the command line, from the main config file, from the plugin config file and from any custom config files. Sensitive plugin argument values get replaced (just like previously) and it prints double-dash argument names instead of the argument's `dest` name, if possible.
- The second commit additionally cleans up `streamlink_cli.main.log_current_versions` and adds tests, for good measure.

----

Example with a mixture of CLI arguments, main-config arguments and plugin-config arguments from my system:
```
$ streamlink -l debug --title foo twitch.tv/esl_csgo best
[cli][debug] OS:         Linux-5.11.7-1-git-x86_64-with-glibc2.33
[cli][debug] Python:     3.9.2
[cli][debug] Streamlink: 2.0.0+64.gce6a60c
[cli][debug] Requests(2.25.1), Socks(1.7.1), Websocket(0.57.0)
[cli][debug] Arguments:
[cli][debug]  url=twitch.tv/esl_csgo
[cli][debug]  stream=['best']
[cli][debug]  --loglevel=debug
[cli][debug]  --player=mpv
[cli][debug]  --player-args=--cache=yes --demuxer-max-bytes=750k --demuxer-max-back-bytes=2G
[cli][debug]  --title=foo
[cli][debug]  --hls-live-edge=2
[cli][debug]  --hls-segment-threads=2
[cli][debug]  --twitch-disable-hosting=True
[cli][debug]  --twitch-disable-ads=True
[cli][debug]  --twitch-disable-reruns=True
[cli][debug]  --twitch-low-latency=True
[cli][info] Found matching plugin twitch for URL twitch.tv/esl_csgo
^CInterrupted! Exiting...
```

gravyboat: Agreed and it looks good. Thanks @bastimeyer.

Patch:
diff --git a/src/streamlink_cli/main.py b/src/streamlink_cli/main.py
index 0dde8761..c48868d2 100644
--- a/src/streamlink_cli/main.py
+++ b/src/streamlink_cli/main.py
@@ -551,21 +551,7 @@ def handle_url():
     try:
         plugin = streamlink.resolve_url(args.url)
         setup_plugin_options(streamlink, plugin)
-        log.info("Found matching plugin {0} for URL {1}".format(
-                 plugin.module, args.url))
-
-        plugin_args = []
-        for parg in plugin.arguments:
-            value = plugin.get_option(parg.dest)
-            if value:
-                plugin_args.append((parg, value))
-
-        if plugin_args:
-            log.debug("Plugin specific arguments:")
-            for parg, value in plugin_args:
-                log.debug(" {0}={1} ({2})".format(parg.argument_name(plugin.module),
-                                                  value if not parg.sensitive else ("*" * 8),
-                                                  parg.dest))
+        log.info(f"Found matching plugin {plugin.module} for URL {args.url}")
 
         if args.retry_max or args.retry_streams:
             retry_streams = 1
@@ -941,22 +927,49 @@ def check_root():
 
 def log_current_versions():
     """Show current installed versions"""
-    if logger.root.isEnabledFor(logging.DEBUG):
-        # MAC OS X
-        if sys.platform == "darwin":
-            os_version = "macOS {0}".format(platform.mac_ver()[0])
-        # Windows
-        elif sys.platform.startswith("win"):
-            os_version = "{0} {1}".format(platform.system(), platform.release())
-        # linux / other
-        else:
-            os_version = platform.platform()
+    if not logger.root.isEnabledFor(logging.DEBUG):
+        return
+
+    # macOS
+    if sys.platform == "darwin":
+        os_version = f"macOS {platform.mac_ver()[0]}"
+    # Windows
+    elif sys.platform == "win32":
+        os_version = f"{platform.system()} {platform.release()}"
+    # Linux / other
+    else:
+        os_version = platform.platform()
+
+    log.debug(f"OS:         {os_version}")
+    log.debug(f"Python:     {platform.python_version()}")
+    log.debug(f"Streamlink: {streamlink_version}")
+    log.debug(f"Requests({requests.__version__}), "
+              f"Socks({socks_version}), "
+              f"Websocket({websocket_version})")
 
-        log.debug("OS:         {0}".format(os_version))
-        log.debug("Python:     {0}".format(platform.python_version()))
-        log.debug("Streamlink: {0}".format(streamlink_version))
-        log.debug("Requests({0}), Socks({1}), Websocket({2})".format(
-            requests.__version__, socks_version, websocket_version))
+
+def log_current_arguments(session, parser):
+    global args
+    if not logger.root.isEnabledFor(logging.DEBUG):
+        return
+
+    sensitive = set()
+    for pname, plugin in session.plugins.items():
+        for parg in plugin.arguments:
+            if parg.sensitive:
+                sensitive.add(parg.argument_name(pname))
+
+    log.debug("Arguments:")
+    for action in parser._actions:
+        if not hasattr(args, action.dest):
+            continue
+        value = getattr(args, action.dest)
+        if action.default != value:
+            name = next(  # pragma: no branch
+                (option for option in action.option_strings if option.startswith("--")),
+                action.option_strings[0]
+            ) if action.option_strings else action.dest
+            log.debug(f" {name}={value if name not in sensitive else '*' * 8}")
 
 
 def check_version(force=False):
@@ -1034,6 +1047,7 @@ def main():
     setup_http_session()
     check_root()
     log_current_versions()
+    log_current_arguments(streamlink, parser)
 
     if args.version_check or args.auto_version_check:
         with ignored(Exception):


Test patch:
diff --git a/tests/plugin/testplugin.py b/tests/plugin/testplugin.py
index 6153ae15..526d9c73 100644
--- a/tests/plugin/testplugin.py
+++ b/tests/plugin/testplugin.py
@@ -2,6 +2,7 @@ from io import BytesIO
 
 from streamlink import NoStreamsError
 from streamlink.options import Options
+from streamlink.plugin import PluginArgument, PluginArguments
 from streamlink.plugins import Plugin
 from streamlink.stream import AkamaiHDStream, HLSStream, HTTPStream, RTMPStream, Stream
 
@@ -14,6 +15,18 @@ class TestStream(Stream):
 
 
 class TestPlugin(Plugin):
+    arguments = PluginArguments(
+        PluginArgument(
+            "bool",
+            action="store_true"
+        ),
+        PluginArgument(
+            "password",
+            metavar="PASSWORD",
+            sensitive=True
+        )
+    )
+
     options = Options({
         "a_option": "default"
     })
diff --git a/tests/test_cli_main.py b/tests/test_cli_main.py
index 9f9865ea..fdf3c847 100644
--- a/tests/test_cli_main.py
+++ b/tests/test_cli_main.py
@@ -5,8 +5,16 @@ from unittest.mock import Mock, call, patch
 
 import streamlink_cli.main
 from streamlink.plugin.plugin import Plugin
+from streamlink.session import Streamlink
 from streamlink_cli.main import (
-    check_file_output, create_output, format_valid_streams, handle_stream, handle_url, resolve_stream_name
+    check_file_output,
+    create_output,
+    format_valid_streams,
+    handle_stream,
+    handle_url,
+    log_current_arguments,
+    log_current_versions,
+    resolve_stream_name
 )
 from streamlink_cli.output import FileOutput, PlayerOutput
 
@@ -254,3 +262,117 @@ class TestCLIMain(unittest.TestCase):
         args.record_and_pipe = True
         create_output(FakePlugin)
         console.exit.assert_called_with("Cannot use record options with other file output options.")
+
+
+@patch("streamlink_cli.main.log")
+@patch("streamlink_cli.main.CONFIG_FILES", ["/dev/null"])
+@patch("streamlink_cli.main.setup_plugins", Mock())
+@patch("streamlink_cli.main.setup_streamlink", Mock())
+@patch("streamlink.session.Streamlink.load_builtin_plugins", Mock())
+class TestCLIMainDebugLogging(unittest.TestCase):
+    def subject(self, argv):
+        session = Streamlink()
+        session.load_plugins(os.path.join(os.path.dirname(__file__), "plugin"))
+
+        with patch("streamlink_cli.main.streamlink", session), patch("sys.argv") as mock_argv:
+            mock_argv.__getitem__.side_effect = lambda x: argv[x]
+            try:
+                streamlink_cli.main.main()
+            except SystemExit:
+                pass
+
+    @patch("streamlink_cli.main.log_current_versions")
+    @patch("streamlink_cli.main.streamlink_version", "streamlink")
+    @patch("streamlink_cli.main.requests.__version__", "requests")
+    @patch("streamlink_cli.main.socks_version", "socks")
+    @patch("streamlink_cli.main.websocket_version", "websocket")
+    @patch("platform.python_version", Mock(return_value="python"))
+    def test_log_current_versions(self, mock_log_current_versions, mock_log):
+        def _log_current_versions():
+            log_current_versions()
+            raise SystemExit
+
+        mock_log_current_versions.side_effect = _log_current_versions
+
+        self.subject(["streamlink", "--loglevel", "info"])
+        self.assertEqual(mock_log.debug.mock_calls, [], "Doesn't log anything if not debug logging")
+
+        with patch("sys.platform", "linux"), \
+             patch("platform.platform", Mock(return_value="linux")):
+            self.subject(["streamlink", "--loglevel", "debug"])
+            self.assertEqual(
+                mock_log.debug.mock_calls,
+                [
+                    call("OS:         linux"),
+                    call("Python:     python"),
+                    call("Streamlink: streamlink"),
+                    call("Requests(requests), Socks(socks), Websocket(websocket)")
+                ]
+            )
+            mock_log.debug.mock_calls.clear()
+
+        with patch("sys.platform", "darwin"), \
+             patch("platform.mac_ver", Mock(return_value=["0.0.0"])):
+            self.subject(["streamlink", "--loglevel", "debug"])
+            self.assertEqual(
+                mock_log.debug.mock_calls,
+                [
+                    call("OS:         macOS 0.0.0"),
+                    call("Python:     python"),
+                    call("Streamlink: streamlink"),
+                    call("Requests(requests), Socks(socks), Websocket(websocket)")
+                ]
+            )
+            mock_log.debug.mock_calls.clear()
+
+        with patch("sys.platform", "win32"), \
+             patch("platform.system", Mock(return_value="Windows")), \
+             patch("platform.release", Mock(return_value="0.0.0")):
+            self.subject(["streamlink", "--loglevel", "debug"])
+            self.assertEqual(
+                mock_log.debug.mock_calls,
+                [
+                    call("OS:         Windows 0.0.0"),
+                    call("Python:     python"),
+                    call("Streamlink: streamlink"),
+                    call("Requests(requests), Socks(socks), Websocket(websocket)")
+                ]
+            )
+            mock_log.debug.mock_calls.clear()
+
+    @patch("streamlink_cli.main.log_current_arguments")
+    @patch("streamlink_cli.main.log_current_versions", Mock())
+    def test_log_current_arguments(self, mock_log_current_arguments, mock_log):
+        def _log_current_arguments(*args, **kwargs):
+            log_current_arguments(*args, **kwargs)
+            raise SystemExit
+
+        mock_log_current_arguments.side_effect = _log_current_arguments
+
+        self.subject([
+            "streamlink",
+            "--loglevel", "info"
+        ])
+        self.assertEqual(mock_log.debug.mock_calls, [], "Doesn't log anything if not debug logging")
+
+        self.subject([
+            "streamlink",
+            "--loglevel", "debug",
+            "-p", "custom",
+            "--testplugin-bool",
+            "--testplugin-password=secret",
+            "website.tld/channel",
+            "best,worst"
+        ])
+        self.assertEqual(
+            mock_log.debug.mock_calls,
+            [
+                call("Arguments:"),
+                call(" url=website.tld/channel"),
+                call(" stream=['best', 'worst']"),
+                call(" --loglevel=debug"),
+                call(" --player=custom"),
+                call(" --testplugin-bool=True"),
+                call(" --testplugin-password=********")
+            ]
+        )