Repository: streamlink/streamlink

Issue:
cli.argparser: nested argparse groups will be deprecated in python 3.11
See
https://github.com/python/cpython/commit/30322c497e0b8d978f7a0de95985aac9c5daf1ac

----

streamlink_cli currently defines nested argparse groups (as well as nested plugin args) for better organization in the docs:

- https://github.com/streamlink/streamlink/blob/4.2.0/src/streamlink_cli/argparser.py#L760-L762
- https://github.com/streamlink/streamlink/blob/4.2.0/src/streamlink_cli/main.py#L861-L864
- https://github.com/streamlink/streamlink/blob/4.2.0/docs/ext_argparse.py#L134-L148
- https://streamlink.github.io/cli.html#stream-transport-options

This needs to be changed and a different way needs to be found to have nested groups in the docs.


Discussion:

Resolving patch:
diff --git a/docs/ext_argparse.py b/docs/ext_argparse.py
--- a/docs/ext_argparse.py
+++ b/docs/ext_argparse.py
@@ -131,21 +131,22 @@ def generate_group_rst(self, group):
                 yield f"    **Supported plugins:** {', '.join(action.plugins)}"
                 yield ""
 
-    def generate_parser_rst(self, parser, depth=0):
+    def generate_parser_rst(self, parser, parent=None, depth=0):
         if depth >= len(self._headlines):
             return
-        for group in parser._action_groups:
+        for group in parser.NESTED_ARGUMENT_GROUPS[parent]:
+            is_parent = group in parser.NESTED_ARGUMENT_GROUPS
             # Exclude empty groups
-            if not group._group_actions and not group._action_groups:
+            if not group._group_actions and not is_parent:
                 continue
             title = group.title
             yield ""
             yield title
             yield self._headlines[depth] * len(title)
             yield from self.generate_group_rst(group)
-            if group._action_groups:
+            if is_parent:
                 yield ""
-                yield from self.generate_parser_rst(group, depth + 1)
+                yield from self.generate_parser_rst(parser, group, depth + 1)
 
     def run(self):
         module = self.options.get("module")
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
@@ -3,6 +3,7 @@
 import re
 from string import printable
 from textwrap import dedent
+from typing import Dict, List, Optional
 
 from streamlink import __version__ as streamlink_version, logger
 from streamlink.utils.args import (
@@ -26,6 +27,27 @@
 
 
 class ArgumentParser(argparse.ArgumentParser):
+    # noinspection PyUnresolvedReferences,PyProtectedMember
+    NESTED_ARGUMENT_GROUPS: Dict[Optional[argparse._ArgumentGroup], List[argparse._ArgumentGroup]]
+
+    def __init__(self, *args, **kwargs):
+        self.NESTED_ARGUMENT_GROUPS = {}
+        super().__init__(*args, **kwargs)
+
+    # noinspection PyUnresolvedReferences,PyProtectedMember
+    def add_argument_group(
+        self,
+        *args,
+        parent: Optional[argparse._ArgumentGroup] = None,
+        **kwargs
+    ) -> argparse._ArgumentGroup:
+        group = super().add_argument_group(*args, **kwargs)
+        if parent not in self.NESTED_ARGUMENT_GROUPS:
+            self.NESTED_ARGUMENT_GROUPS[parent] = [group]
+        else:
+            self.NESTED_ARGUMENT_GROUPS[parent].append(group)
+        return group
+
     def convert_arg_line_to_args(self, line):
         # Strip any non-printable characters that might be in the
         # beginning of the line (e.g. Unicode BOM marker).
@@ -87,16 +109,18 @@ def format_help(self):
         # description
         formatter.add_text(self.description)
 
-        def format_group(group):
+        def format_group(parent):
+            if parent not in self.NESTED_ARGUMENT_GROUPS:
+                return
             # positionals, optionals and user-defined groups
-            for action_group in group._action_groups:
+            for action_group in self.NESTED_ARGUMENT_GROUPS[parent]:
                 formatter.start_section(action_group.title)
                 formatter.add_text(action_group.description)
                 formatter.add_arguments(action_group._group_actions)
                 format_group(action_group)
                 formatter.end_section()
 
-        format_group(self)
+        format_group(None)
 
         # epilog
         formatter.add_text(self.epilog)
@@ -118,11 +142,10 @@ class HelpFormatter(argparse.RawDescriptionHelpFormatter):
     def __init__(self, max_help_position=4, *args, **kwargs):
         # A smaller indent for args help.
         kwargs["max_help_position"] = max_help_position
-        argparse.RawDescriptionHelpFormatter.__init__(self, *args, **kwargs)
+        super().__init__(*args, **kwargs)
 
     def _split_lines(self, text, width):
-        text = dedent(text).strip() + "\n\n"
-        return text.splitlines()
+        return f"{dedent(text).strip()}\n\n".splitlines()
 
 
 def build_parser():
@@ -758,8 +781,8 @@ def build_parser():
     )
 
     transport = parser.add_argument_group("Stream transport options")
-    transport_hls = transport.add_argument_group("HLS options")
-    transport_ffmpeg = transport.add_argument_group("FFmpeg options")
+    transport_hls = parser.add_argument_group("HLS options", parent=transport)
+    transport_ffmpeg = parser.add_argument_group("FFmpeg options", parent=transport)
 
     transport.add_argument(
         "--ringbuffer-size",
@@ -1167,4 +1190,4 @@ def build_parser():
     return parser
 
 
-__all__ = ["build_parser"]
+__all__ = ["ArgumentParser", "build_parser"]
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
@@ -24,7 +24,7 @@
 from streamlink.plugin import Plugin, PluginOptions
 from streamlink.stream.stream import Stream, StreamIO
 from streamlink.utils.named_pipe import NamedPipe
-from streamlink_cli.argparser import build_parser
+from streamlink_cli.argparser import ArgumentParser, build_parser
 from streamlink_cli.compat import DeprecatedPath, importlib_metadata, is_win32, stdout
 from streamlink_cli.console import ConsoleOutput, ConsoleUserInputRequester
 from streamlink_cli.constants import CONFIG_FILES, DEFAULT_STREAM_METADATA, LOG_DIR, PLUGIN_DIRS, STREAM_SYNONYMS
@@ -855,13 +855,13 @@ def setup_options():
     streamlink.set_option("locale", args.locale)
 
 
-def setup_plugin_args(session, parser):
+def setup_plugin_args(session: Streamlink, parser: ArgumentParser):
     """Sets Streamlink plugin options."""
 
     plugin_args = parser.add_argument_group("Plugin options")
     for pname, plugin in session.plugins.items():
         defaults = {}
-        group = plugin_args.add_argument_group(pname.capitalize())
+        group = parser.add_argument_group(pname.capitalize(), parent=plugin_args)
 
         for parg in plugin.arguments:
             if not parg.is_global:


Test patch:
diff --git a/tests/plugins/test_funimationnow.py b/tests/plugins/test_funimationnow.py
--- a/tests/plugins/test_funimationnow.py
+++ b/tests/plugins/test_funimationnow.py
@@ -22,7 +22,8 @@ def test_arguments(self):
         from streamlink_cli.main import setup_plugin_args
         session = Streamlink()
         parser = MagicMock()
-        group = parser.add_argument_group("Plugin Options").add_argument_group("FunimationNow")
+        plugins = parser.add_argument_group("Plugin Options")
+        group = parser.add_argument_group("FunimationNow", parent=plugins)
 
         session.plugins = {
             'funimationnow': FunimationNow
diff --git a/tests/plugins/test_ustreamtv.py b/tests/plugins/test_ustreamtv.py
--- a/tests/plugins/test_ustreamtv.py
+++ b/tests/plugins/test_ustreamtv.py
@@ -25,7 +25,8 @@ def test_arguments(self):
         from streamlink_cli.main import setup_plugin_args
         session = Streamlink()
         parser = MagicMock()
-        group = parser.add_argument_group("Plugin Options").add_argument_group("UStreamTV")
+        plugins = parser.add_argument_group("Plugin Options")
+        group = parser.add_argument_group("UStreamTV", parent=plugins)
 
         session.plugins = {
             'ustreamtv': UStreamTV
diff --git a/tests/test_options.py b/tests/test_options.py
--- a/tests/test_options.py
+++ b/tests/test_options.py
@@ -3,6 +3,7 @@
 from unittest.mock import Mock, patch
 
 from streamlink.options import Argument, Arguments, Options
+from streamlink_cli.argparser import ArgumentParser
 from streamlink_cli.main import setup_plugin_args, setup_plugin_options
 
 
@@ -116,7 +117,7 @@ class TestSetupOptions(unittest.TestCase):
     def test_setup_plugin_args(self):
         session = Mock()
         plugin = Mock()
-        parser = argparse.ArgumentParser(add_help=False)
+        parser = ArgumentParser(add_help=False)
         parser.add_argument("--global-arg1", default=123)
         parser.add_argument("--global-arg2", default=456)
 
@@ -131,9 +132,11 @@ def test_setup_plugin_args(self):
         setup_plugin_args(session, parser)
 
         group_plugins = next((grp for grp in parser._action_groups if grp.title == "Plugin options"), None)  # pragma: no branch
-        self.assertIsNotNone(group_plugins, "Adds the 'Plugin options' arguments group")
-        group_plugin = next((grp for grp in group_plugins._action_groups if grp.title == "Mock"), None)  # pragma: no branch
-        self.assertIsNotNone(group_plugin, "Adds the 'Mock' arguments group to the 'Plugin options' group")
+        assert group_plugins is not None, "Adds the 'Plugin options' arguments group"
+        assert group_plugins in parser.NESTED_ARGUMENT_GROUPS[None], "Adds the 'Plugin options' arguments group"
+        group_plugin = next((grp for grp in parser._action_groups if grp.title == "Mock"), None)  # pragma: no branch
+        assert group_plugin is not None, "Adds the 'Mock' arguments group"
+        assert group_plugin in parser.NESTED_ARGUMENT_GROUPS[group_plugins], "Adds the 'Mock' arguments group"
         self.assertEqual(
             [item for action in group_plugin._group_actions for item in action.option_strings],
             ["--mock-test1", "--mock-test2", "--mock-test3"],
@@ -154,7 +157,7 @@ def test_setup_plugin_args(self):
     def test_setup_plugin_options(self):
         session = Mock()
         plugin = Mock(module="plugin")
-        parser = argparse.ArgumentParser()
+        parser = ArgumentParser()
         parser.add_argument("--foo-foo", default=123)
 
         session.plugins = {"plugin": plugin}