Repository: streamlink/streamlink

Issue:
streamlink -h is raising TypeError: not enough arguments for format string
### Checklist

- [X] This is a bug report and not a different kind of issue
- [X] [I have read the contribution guidelines](https://github.com/streamlink/streamlink/blob/master/CONTRIBUTING.md#contributing-to-streamlink)
- [X] [I have checked the list of open and recently closed bug reports](https://github.com/streamlink/streamlink/issues?q=is%3Aissue+label%3A%22bug%22)
- [X] [I have checked the commit log of the master branch](https://github.com/streamlink/streamlink/commits/master)

### Streamlink version

Latest stable release

### Description

Hi everyone. 
Just as the title says, the latest version from pip is raising the above error due to non-escaped % in the argparser.py. 

Specifically, in the three lines on the file argparser.py that contains:

`{time:%Y%m%d%H%M%S}`

replacing with

`{time:%%Y%%m%%d%%H%%M%%S}`

should fix the issue.

Thanks for the great work anyway,
takeshi


### Debug log

```text
Traceback (most recent call last):
  File "/home/user/.local/bin/streamlink", line 8, in <module>
    sys.exit(main())
  File "/home/user/.local/lib/python3.8/site-packages/streamlink_cli/main.py", line 1069, in main
    parser.print_help()
  File "/usr/lib/python3.8/argparse.py", line 2509, in print_help
    self._print_message(self.format_help(), file)
  File "/home/user/.local/lib/python3.8/site-packages/streamlink_cli/argparser.py", line 105, in format_help
    return formatter.format_help()
  File "/usr/lib/python3.8/argparse.py", line 294, in format_help
    help = self._root_section.format_help()
  File "/usr/lib/python3.8/argparse.py", line 225, in format_help
    item_help = join([func(*args) for func, args in self.items])
  File "/usr/lib/python3.8/argparse.py", line 225, in <listcomp>
    item_help = join([func(*args) for func, args in self.items])
  File "/usr/lib/python3.8/argparse.py", line 225, in format_help
    item_help = join([func(*args) for func, args in self.items])
  File "/usr/lib/python3.8/argparse.py", line 225, in <listcomp>
    item_help = join([func(*args) for func, args in self.items])
  File "/usr/lib/python3.8/argparse.py", line 541, in _format_action
    help_text = self._expand_help(action)
  File "/usr/lib/python3.8/argparse.py", line 636, in _expand_help
    return self._get_help_string(action) % params
TypeError: not enough arguments for format string
```



Discussion:

Resolving patch:
diff --git a/src/streamlink_cli/argparser.py b/src/streamlink_cli/argparser.py
--- a/src/streamlink_cli/argparser.py
+++ b/src/streamlink_cli/argparser.py
@@ -548,7 +548,7 @@ def build_parser():
 
         Example:
 
-            %(prog)s --output "~/recordings/{author}/{category}/{id}-{time:%Y%m%d%H%M%S}.ts" <URL> [STREAM]
+            %(prog)s --output "~/recordings/{author}/{category}/{id}-{time:%%Y%%m%%d%%H%%M%%S}.ts" <URL> [STREAM]
         """
     )
     output.add_argument(
@@ -587,7 +587,7 @@ def build_parser():
 
         Example:
 
-            %(prog)s --record "~/recordings/{author}/{category}/{id}-{time:%Y%m%d%H%M%S}.ts" <URL> [STREAM]
+            %(prog)s --record "~/recordings/{author}/{category}/{id}-{time:%%Y%%m%%d%%H%%M%%S}.ts" <URL> [STREAM]
         """
     )
     output.add_argument(
@@ -604,7 +604,7 @@ def build_parser():
 
         Example:
 
-            %(prog)s --record-and-pipe "~/recordings/{author}/{category}/{id}-{time:%Y%m%d%H%M%S}.ts" <URL> [STREAM]
+            %(prog)s --record-and-pipe "~/recordings/{author}/{category}/{id}-{time:%%Y%%m%%d%%H%%M%%S}.ts" <URL> [STREAM]
         """
     )
     output.add_argument(
diff --git a/src/streamlink_cli/main.py b/src/streamlink_cli/main.py
--- a/src/streamlink_cli/main.py
+++ b/src/streamlink_cli/main.py
@@ -1032,7 +1032,9 @@ def main():
         with ignored(Exception):
             check_version(force=args.version_check)
 
-    if args.plugins:
+    if args.help:
+        parser.print_help()
+    elif args.plugins:
         print_plugins()
     elif args.can_handle_url:
         try:
@@ -1065,8 +1067,6 @@ def main():
                     stream_fd.close()
                 except KeyboardInterrupt:
                     error_code = 130
-    elif args.help:
-        parser.print_help()
     else:
         usage = parser.format_usage()
         console.msg(


Test patch:
diff --git a/tests/test_cli_main.py b/tests/test_cli_main.py
--- a/tests/test_cli_main.py
+++ b/tests/test_cli_main.py
@@ -3,6 +3,7 @@
 import sys
 import unittest
 from pathlib import Path, PosixPath, WindowsPath
+from textwrap import dedent
 from unittest.mock import Mock, call, patch
 
 import freezegun
@@ -740,3 +741,78 @@ def test_logfile_path_auto(self, mock_open, mock_stdout):
             mock_write=mock_open("C:\\foo\\2000-01-02_03-04-05.log", "a").write,
             mock_stdout=mock_stdout
         )
+
+
+class TestCLIMainPrint(unittest.TestCase):
+    def subject(self):
+        with patch.object(Streamlink, "load_builtin_plugins"), \
+             patch.object(Streamlink, "resolve_url") as mock_resolve_url, \
+             patch.object(Streamlink, "resolve_url_no_redirect") as mock_resolve_url_no_redirect:
+            session = Streamlink()
+            session.load_plugins(os.path.join(os.path.dirname(__file__), "plugin"))
+            with patch("streamlink_cli.main.streamlink", session), \
+                 patch("streamlink_cli.main.CONFIG_FILES", []), \
+                 patch("streamlink_cli.main.setup_streamlink"), \
+                 patch("streamlink_cli.main.setup_plugins"), \
+                 patch("streamlink_cli.main.setup_http_session"), \
+                 patch("streamlink_cli.main.setup_signals"), \
+                 patch("streamlink_cli.main.setup_options") as mock_setup_options:
+                with self.assertRaises(SystemExit) as cm:
+                    streamlink_cli.main.main()
+                self.assertEqual(cm.exception.code, 0)
+                mock_resolve_url.assert_not_called()
+                mock_resolve_url_no_redirect.assert_not_called()
+                mock_setup_options.assert_not_called()
+
+    @staticmethod
+    def get_stdout(mock_stdout):
+        return "".join([call_arg[0][0] for call_arg in mock_stdout.write.call_args_list])
+
+    @patch("sys.stdout")
+    @patch("sys.argv", ["streamlink"])
+    def test_print_usage(self, mock_stdout):
+        self.subject()
+        self.assertEqual(
+            self.get_stdout(mock_stdout),
+            "usage: streamlink [OPTIONS] <URL> [STREAM]\n\n"
+            + "Use -h/--help to see the available options or read the manual at https://streamlink.github.io\n"
+        )
+
+    @patch("sys.stdout")
+    @patch("sys.argv", ["streamlink", "--help"])
+    def test_print_help(self, mock_stdout):
+        self.subject()
+        output = self.get_stdout(mock_stdout)
+        self.assertIn(
+            "usage: streamlink [OPTIONS] <URL> [STREAM]",
+            output
+        )
+        self.assertIn(
+            dedent("""
+                Streamlink is a command-line utility that extracts streams from various
+                services and pipes them into a video player of choice.
+            """),
+            output
+        )
+        self.assertIn(
+            dedent("""
+                For more in-depth documentation see:
+                  https://streamlink.github.io
+
+                Please report broken plugins or bugs to the issue tracker on Github:
+                  https://github.com/streamlink/streamlink/issues
+            """),
+            output
+        )
+
+    @patch("sys.stdout")
+    @patch("sys.argv", ["streamlink", "--plugins"])
+    def test_print_plugins(self, mock_stdout):
+        self.subject()
+        self.assertEqual(self.get_stdout(mock_stdout), "Loaded plugins: testplugin\n")
+
+    @patch("sys.stdout")
+    @patch("sys.argv", ["streamlink", "--plugins", "--json"])
+    def test_print_plugins_json(self, mock_stdout):
+        self.subject()
+        self.assertEqual(self.get_stdout(mock_stdout), """[\n  "testplugin"\n]\n""")