Repository: streamlink/streamlink

Issue:
ipv4 setting in config file doesn't work
### 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

With `ipv4` in the config file, streamlink --loglevel=debug $URL produces:
```
usage: streamlink [OPTIONS] <URL> [STREAM]
streamlink: error: ambiguous option: --ipv=4 could match --ipv4, --ipv6
```
With just `4`, it works

### Debug log

```text
streamlink --loglevel=debug <url>
usage: streamlink [OPTIONS] <URL> [STREAM]
streamlink: error: ambiguous option: --ipv=4 could match --ipv4, --ipv6
```



Discussion:
Thanks. Looks like it's caused by this poor regex:
- https://github.com/streamlink/streamlink/blame/6.0.1/src/streamlink_cli/argparser.py#L19-L26
- https://github.com/streamlink/streamlink/blame/6.0.1/src/streamlink_cli/argparser.py#L59-L62

Tests were never added to this `argparse.ArgumentParser` override:
https://app.codecov.io/gh/streamlink/streamlink/commit/4a234ff76a73ad238eea8470267366592963ad50/blob/src/streamlink_cli/argparser.py#L51

Going to fix this later today...
See #5484

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
@@ -15,21 +15,13 @@
 from streamlink_cli.utils import find_default_player
 
 
-_printable_re = re.compile(r"[{0}]".format(printable))
-_option_re = re.compile(r"""
-    (?P<name>[A-z-]+) # A option name, valid characters are A to z and dash.
-    \s*
-    (?P<op>=)? # Separating the option and the value with a equals sign is
-               # common, but optional.
-    \s*
-    (?P<value>.*) # The value, anything goes.
-""", re.VERBOSE)
-
-
 class ArgumentParser(argparse.ArgumentParser):
     # noinspection PyUnresolvedReferences,PyProtectedMember
     NESTED_ARGUMENT_GROUPS: Dict[Optional[argparse._ArgumentGroup], List[argparse._ArgumentGroup]]
 
+    _RE_PRINTABLE = re.compile(fr"[{re.escape(printable)}]")
+    _RE_OPTION = re.compile(r"^(?P<name>[A-Za-z0-9-]+)(?:(?P<op>\s*=\s*|\s+)(?P<value>.*))?$")
+
     def __init__(self, *args, **kwargs):
         self.NESTED_ARGUMENT_GROUPS = {}
         super().__init__(*args, **kwargs)
@@ -51,21 +43,22 @@ def add_argument_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).
-        match = _printable_re.search(line)
+        match = self._RE_PRINTABLE.search(line)
         if not match:
             return
         line = line[match.start():].strip()
 
         # Skip lines that do not start with a valid option (e.g. comments)
-        option = _option_re.match(line)
+        option = self._RE_OPTION.match(line)
         if not option:
             return
 
-        name, value = option.group("name", "value")
-        if name and value:
-            yield f"--{name}={value}"
-        elif name:
-            yield f"--{name}"
+        name, op, value = option.group("name", "op", "value")
+        prefix = self.prefix_chars[0] if len(name) == 1 else self.prefix_chars[0] * 2
+        if value or op:
+            yield f"{prefix}{name}={value}"
+        else:
+            yield f"{prefix}{name}"
 
     def _match_argument(self, action, arg_strings_pattern):
         # - https://github.com/streamlink/streamlink/issues/971


Test patch:
diff --git a/tests/cli/test_argparser.py b/tests/cli/test_argparser.py
--- a/tests/cli/test_argparser.py
+++ b/tests/cli/test_argparser.py
@@ -1,4 +1,6 @@
+import argparse
 from argparse import ArgumentParser, Namespace
+from pathlib import Path
 from typing import Any, List
 from unittest.mock import Mock
 
@@ -14,6 +16,65 @@ def parser():
     return build_parser()
 
 
+class TestConfigFileArguments:
+    @pytest.fixture()
+    def parsed(self, request: pytest.FixtureRequest, parser: argparse.ArgumentParser, tmp_path: Path):
+        content = "\n".join([
+            "",
+            " ",
+            "# comment",
+            "! comment",
+            "invalid_option_format",
+            *getattr(request, "param", []),
+        ])
+
+        config = tmp_path / "config"
+        with config.open("w") as fd:
+            fd.write(content)
+
+        return parser.parse_args([f"@{config}"])
+
+    @pytest.mark.parametrize("parsed", [[]], indirect=True)
+    def test_nooptions(self, parsed: Namespace):
+        assert parsed.ipv4 is None
+        assert parsed.player_fifo is False
+        assert parsed.player_args == ""
+        assert parsed.title is None
+
+    @pytest.mark.parametrize("parsed", [
+        pytest.param(["4"], id="shorthand name"),
+        pytest.param(["ipv4"], id="full name"),
+    ], indirect=True)
+    def test_alphanumerical(self, parsed: Namespace):
+        assert parsed.ipv4 is True
+
+    @pytest.mark.parametrize("parsed", [
+        pytest.param(["n"], id="shorthand name"),
+        pytest.param(["player-fifo"], id="full name"),
+    ], indirect=True)
+    def test_withoutvalue(self, parsed: Namespace):
+        assert parsed.player_fifo is True
+
+    @pytest.mark.parametrize("parsed", [
+        pytest.param(["a=foo bar "], id="shorthand name with operator"),
+        pytest.param(["a = foo bar "], id="shorthand name with operator and surrounding whitespace"),
+        pytest.param(["a   foo bar "], id="shorthand name without operator"),
+        pytest.param(["player-args=foo bar "], id="full name with operator"),
+        pytest.param(["player-args = foo bar "], id="full name with operator and surrounding whitespace"),
+        pytest.param(["player-args   foo bar "], id="full name without operator"),
+    ], indirect=True)
+    def test_withvalue(self, parsed: Namespace):
+        assert parsed.player_args == "foo bar"
+
+    @pytest.mark.parametrize("parsed", [
+        pytest.param(["title="], id="operator"),
+        pytest.param(["title ="], id="operator with leading whitespace"),
+        pytest.param(["title = "], id="operator with surrounding whitespace"),
+    ], indirect=True)
+    def test_emptyvalue(self, parsed: Namespace):
+        assert parsed.title == ""
+
+
 @pytest.mark.filterwarnings("ignore")
 @pytest.mark.parametrize(("argv", "option", "expected"), [
     pytest.param(