Repository: streamlink/streamlink
Pull request #3784 (2021-06-06 20:21:20)

cli: deprecate old config files and plugin dirs
# Deprecated config file paths

- **Windows**
  - `%APPDATA%\streamlink\streamlinkrc`
- **macOS**
  - `${XDG_CONFIG_HOME:-${HOME}/.config}/streamlink/config`
  - `${HOME}/.streamlinkrc`
- **Linux/BSD**
  - `${HOME}/.streamlinkrc`

# Deprecated plugin directories

- **macOS**
  - `${XDG_CONFIG_HOME:-${HOME}/.config}/streamlink/plugins`
- **Linux/BSD**
  - `${XDG_CONFIG_HOME:-${HOME}/.config}/streamlink/plugins`

----

## Updated primary config file paths

- **Windows**
  `%APPDATA%\streamlink\config`
- **macOS**
  `${HOME}/Library/Application Support/streamlink/config`
- **Linux/BSD**
  `${XDG_CONFIG_HOME:-${HOME}/.config}/streamlink/config` (no changes)

## Updated primary plugin directories

- **Windows**
  `%APPDATA%\streamlink\plugins` (no changes)
- **macOS**
  `${HOME}/Library/Application Support/streamlink/plugins`
- **Linux/BSD**
  `${XDG_DATA_HOME:-${HOME}/.local/share}/streamlink/plugins`


Patch:
diff --git a/docs/cli.rst b/docs/cli.rst
index c3c0754c..3c6f7bf9 100644
--- a/docs/cli.rst
+++ b/docs/cli.rst
@@ -96,11 +96,20 @@ your platform:
 Platform          Location
 ================= ====================================================
 Linux, BSD        - ``${XDG_CONFIG_HOME:-${HOME}/.config}/streamlink/config``
+
+                  Deprecated:
+
                   - ``${HOME}/.streamlinkrc``
 macOS             - ``${HOME}/Library/Application Support/streamlink/config``
+
+                  Deprecated:
+
                   - ``${XDG_CONFIG_HOME:-${HOME}/.config}/streamlink/config``
                   - ``${HOME}/.streamlinkrc``
 Windows           - ``%APPDATA%\streamlink\config``
+
+                  Deprecated:
+
                   - ``%APPDATA%\streamlink\streamlinkrc``
 ================= ====================================================
 
@@ -163,11 +172,20 @@ Examples
 Platform          Location
 ================= ====================================================
 Linux, BSD        - ``${XDG_CONFIG_HOME:-${HOME}/.config}/streamlink/config.pluginname``
+
+                  Deprecated:
+
                   - ``${HOME}/.streamlinkrc.pluginname``
 macOS             - ``${HOME}/Library/Application Support/streamlink/config.pluginname``
+
+                  Deprecated:
+
                   - ``${XDG_CONFIG_HOME:-${HOME}/.config}/streamlink/config.pluginname``
                   - ``${HOME}/.streamlinkrc.pluginname``
 Windows           - ``%APPDATA%\streamlink\config.pluginname``
+
+                  Deprecated:
+
                   - ``%APPDATA%\streamlink\streamlinkrc.pluginname``
 ================= ====================================================
 
@@ -186,8 +204,14 @@ Streamlink will attempt to load standalone plugins from these directories:
 Platform          Location
 ================= ====================================================
 Linux, BSD        - ``${XDG_DATA_HOME:-${HOME}/.local/share}/streamlink/plugins``
+
+                  Deprecated:
+
                   - ``${XDG_CONFIG_HOME:-${HOME}/.config}/streamlink/plugins``
 macOS             - ``${HOME}/Library/Application Support/streamlink/plugins``
+
+                  Deprecated:
+
                   - ``${XDG_CONFIG_HOME:-${HOME}/.config}/streamlink/plugins``
 Windows           - ``%APPDATA%\streamlink\plugins``
 ================= ====================================================
diff --git a/src/streamlink/session.py b/src/streamlink/session.py
index 1cce091c..d9a617a0 100644
--- a/src/streamlink/session.py
+++ b/src/streamlink/session.py
@@ -444,12 +444,13 @@ class Streamlink:
     def load_builtin_plugins(self):
         self.load_plugins(plugins.__path__[0])
 
-    def load_plugins(self, path):
+    def load_plugins(self, path: str) -> bool:
         """Attempt to load plugins from the path specified.
 
         :param path: full path to a directory where to look for plugins
-
+        :return: success
         """
+        success = False
         user_input_requester = self.get_option("user-input-requester")
         for loader, name, ispkg in pkgutil.iter_modules([path]):
             # set the full plugin module name
@@ -462,12 +463,15 @@ class Streamlink:
 
             if not hasattr(mod, "__plugin__") or not issubclass(mod.__plugin__, Plugin):
                 continue
+            success = True
             plugin = mod.__plugin__
             plugin.bind(self, name, user_input_requester)
             if plugin.module in self.plugins:
                 log.debug(f"Plugin {plugin.module} is being overridden by {mod.__file__}")
             self.plugins[plugin.module] = plugin
 
+        return success
+
     @property
     def version(self):
         return __version__
diff --git a/src/streamlink_cli/compat.py b/src/streamlink_cli/compat.py
index 15f70b80..052177ac 100644
--- a/src/streamlink_cli/compat.py
+++ b/src/streamlink_cli/compat.py
@@ -1,5 +1,7 @@
 import os
 import sys
+from pathlib import Path
+
 
 is_darwin = sys.platform == "darwin"
 is_win32 = os.name == "nt"
@@ -7,4 +9,8 @@ is_win32 = os.name == "nt"
 stdout = sys.stdout.buffer
 
 
-__all__ = ["is_darwin", "is_win32", "stdout"]
+class DeprecatedPath(type(Path())):
+    pass
+
+
+__all__ = ["is_darwin", "is_win32", "stdout", "DeprecatedPath"]
diff --git a/src/streamlink_cli/constants.py b/src/streamlink_cli/constants.py
index 1fab2618..110d07d3 100644
--- a/src/streamlink_cli/constants.py
+++ b/src/streamlink_cli/constants.py
@@ -3,7 +3,7 @@ import tempfile
 from pathlib import Path
 from typing import List
 
-from streamlink_cli.compat import is_darwin, is_win32
+from streamlink_cli.compat import DeprecatedPath, is_darwin, is_win32
 
 PLAYER_ARGS_INPUT_DEFAULT = "playerinput"
 PLAYER_ARGS_INPUT_FALLBACK = "filename"
@@ -31,7 +31,7 @@ if is_win32:
     APPDATA = Path(os.environ.get("APPDATA") or Path.home() / "AppData")
     CONFIG_FILES = [
         APPDATA / "streamlink" / "config",
-        APPDATA / "streamlink" / "streamlinkrc"
+        DeprecatedPath(APPDATA / "streamlink" / "streamlinkrc")
     ]
     PLUGIN_DIRS = [
         APPDATA / "streamlink" / "plugins"
@@ -41,25 +41,25 @@ elif is_darwin:
     XDG_CONFIG_HOME = Path(os.environ.get("XDG_CONFIG_HOME", "~/.config")).expanduser()
     CONFIG_FILES = [
         Path.home() / "Library" / "Application Support" / "streamlink" / "config",
-        XDG_CONFIG_HOME / "streamlink" / "config",
-        Path.home() / ".streamlinkrc"
+        DeprecatedPath(XDG_CONFIG_HOME / "streamlink" / "config"),
+        DeprecatedPath(Path.home() / ".streamlinkrc")
     ]
     PLUGIN_DIRS = [
         Path.home() / "Library" / "Application Support" / "streamlink" / "plugins",
-        XDG_CONFIG_HOME / "streamlink" / "plugins"
+        DeprecatedPath(XDG_CONFIG_HOME / "streamlink" / "plugins")
     ]
-    LOG_DIR = Path.home() / "Library" / "Logs" / "streamlink"
+    LOG_DIR = DeprecatedPath(Path.home() / "Library" / "Logs" / "streamlink")
 else:
     XDG_CONFIG_HOME = Path(os.environ.get("XDG_CONFIG_HOME", "~/.config")).expanduser()
     XDG_DATA_HOME = Path(os.environ.get("XDG_DATA_HOME", "~/.local/share")).expanduser()
     XDG_STATE_HOME = Path(os.environ.get("XDG_STATE_HOME", "~/.local/state")).expanduser()
     CONFIG_FILES = [
         XDG_CONFIG_HOME / "streamlink" / "config",
-        Path.home() / ".streamlinkrc"
+        DeprecatedPath(Path.home() / ".streamlinkrc")
     ]
     PLUGIN_DIRS = [
         XDG_DATA_HOME / "streamlink" / "plugins",
-        XDG_CONFIG_HOME / "streamlink" / "plugins"
+        DeprecatedPath(XDG_CONFIG_HOME / "streamlink" / "plugins")
     ]
     LOG_DIR = XDG_STATE_HOME / "streamlink" / "logs"
 
diff --git a/src/streamlink_cli/main.py b/src/streamlink_cli/main.py
index 24d17332..d270ed37 100644
--- a/src/streamlink_cli/main.py
+++ b/src/streamlink_cli/main.py
@@ -28,7 +28,7 @@ from streamlink.plugin import PluginOptions
 from streamlink.stream import StreamProcess
 from streamlink.utils import LazyFormatter, NamedPipe
 from streamlink_cli.argparser import build_parser
-from streamlink_cli.compat import is_win32, stdout
+from streamlink_cli.compat import DeprecatedPath, 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
 from streamlink_cli.output import FileOutput, PlayerOutput
@@ -620,7 +620,9 @@ def load_plugins(dirs: List[Path], showwarning: bool = True):
     """Attempts to load plugins from a list of directories."""
     for directory in dirs:
         if directory.is_dir():
-            streamlink.load_plugins(str(directory))
+            success = streamlink.load_plugins(str(directory))
+            if success and type(directory) is DeprecatedPath:
+                log.info(f"Loaded plugins from deprecated path, see CLI docs for how to migrate: {directory}")
         elif showwarning:
             log.warning(f"Plugin path {directory} does not exist or is not a directory!")
 
@@ -631,10 +633,9 @@ def setup_args(parser: argparse.ArgumentParser, config_files: List[Path] = None,
     arglist = sys.argv[1:]
 
     # Load arguments from config files
-    for config_file in filter(lambda path: path.is_file(), config_files or []):
-        arglist.insert(0, f"@{config_file}")
+    configs = [f"@{config_file}" for config_file in config_files or []]
 
-    args, unknown = parser.parse_known_args(arglist)
+    args, unknown = parser.parse_known_args(configs + arglist)
     if unknown and not ignore_unknown:
         msg = gettext('unrecognized arguments: %s')
         parser.error(msg % ' '.join(unknown))
@@ -650,20 +651,32 @@ def setup_args(parser: argparse.ArgumentParser, config_files: List[Path] = None,
 def setup_config_args(parser, ignore_unknown=False):
     config_files = []
 
-    if streamlink and args.url:
-        with ignored(NoPluginError):
-            plugin = streamlink.resolve_url(args.url)
-            config_files += [path.with_name(f"{path.name}.{plugin.module}") for path in CONFIG_FILES]
-
     if args.config:
         # We want the config specified last to get highest priority
-        config_files += map(lambda path: Path(path).expanduser(), reversed(args.config))
+        for config_file in map(lambda path: Path(path).expanduser(), reversed(args.config)):
+            if config_file.is_file():
+                config_files.append(config_file)
     else:
         # Only load first available default config
         for config_file in filter(lambda path: path.is_file(), CONFIG_FILES):
+            if type(config_file) is DeprecatedPath:
+                log.info(f"Loaded config from deprecated path, see CLI docs for how to migrate: {config_file}")
             config_files.append(config_file)
             break
 
+    if streamlink and args.url:
+        # Only load first available plugin config
+        with ignored(NoPluginError):
+            plugin = streamlink.resolve_url(args.url)
+            for config_file in CONFIG_FILES:
+                config_file = config_file.with_name(f"{config_file.name}.{plugin.module}")
+                if not config_file.is_file():
+                    continue
+                if type(config_file) is DeprecatedPath:
+                    log.info(f"Loaded plugin config from deprecated path, see CLI docs for how to migrate: {config_file}")
+                config_files.append(config_file)
+                break
+
     if config_files:
         setup_args(parser, config_files, ignore_unknown=ignore_unknown)
 


Test patch:
diff --git a/tests/resources/cli/config/custom b/tests/resources/cli/config/custom
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/resources/cli/config/primary b/tests/resources/cli/config/primary
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/resources/cli/config/primary.testplugin b/tests/resources/cli/config/primary.testplugin
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/resources/cli/config/secondary b/tests/resources/cli/config/secondary
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/resources/cli/config/secondary.testplugin b/tests/resources/cli/config/secondary.testplugin
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/test_cli_main.py b/tests/test_cli_main.py
index 718ef526..2fbb3330 100644
--- a/tests/test_cli_main.py
+++ b/tests/test_cli_main.py
@@ -9,17 +9,20 @@ from unittest.mock import Mock, call, patch
 import freezegun
 
 import streamlink_cli.main
+import tests.resources
 from streamlink.plugin.plugin import Plugin
 from streamlink.session import Streamlink
-from streamlink_cli.compat import is_win32
+from streamlink_cli.compat import DeprecatedPath, is_win32
 from streamlink_cli.main import (
+    NoPluginError,
     check_file_output,
     create_output,
     format_valid_streams,
     handle_stream,
     handle_url,
     log_current_arguments,
-    resolve_stream_name
+    resolve_stream_name,
+    setup_config_args
 )
 from streamlink_cli.output import FileOutput, PlayerOutput
 
@@ -269,6 +272,90 @@ class TestCLIMain(unittest.TestCase):
         console.exit.assert_called_with("Cannot use record options with other file output options.")
 
 
+@patch("streamlink_cli.main.log")
+class TestCLIMainSetupConfigArgs(unittest.TestCase):
+    configdir = Path(tests.resources.__path__[0], "cli", "config")
+    parser = Mock()
+
+    @classmethod
+    def subject(cls, config_files, **args):
+        def resolve_url(name):
+            if name == "noplugin":
+                raise NoPluginError()
+            return Mock(module="testplugin")
+
+        session = Mock()
+        session.resolve_url.side_effect = resolve_url
+        args.setdefault("url", "testplugin")
+
+        with patch("streamlink_cli.main.setup_args") as mock_setup_args, \
+             patch("streamlink_cli.main.args", **args), \
+             patch("streamlink_cli.main.streamlink", session), \
+             patch("streamlink_cli.main.CONFIG_FILES", config_files):
+            setup_config_args(cls.parser)
+            return mock_setup_args
+
+    def test_no_plugin(self, mock_log):
+        mock_setup_args = self.subject(
+            [self.configdir / "primary", DeprecatedPath(self.configdir / "secondary")],
+            config=None,
+            url="noplugin"
+        )
+        expected = [self.configdir / "primary"]
+        mock_setup_args.assert_called_once_with(self.parser, expected, ignore_unknown=False)
+        self.assertEqual(mock_log.info.mock_calls, [])
+
+    def test_default_primary(self, mock_log):
+        mock_setup_args = self.subject(
+            [self.configdir / "primary", DeprecatedPath(self.configdir / "secondary")],
+            config=None
+        )
+        expected = [self.configdir / "primary", self.configdir / "primary.testplugin"]
+        mock_setup_args.assert_called_once_with(self.parser, expected, ignore_unknown=False)
+        self.assertEqual(mock_log.info.mock_calls, [])
+
+    def test_default_secondary_deprecated(self, mock_log):
+        mock_setup_args = self.subject(
+            [self.configdir / "non-existent", DeprecatedPath(self.configdir / "secondary")],
+            config=None
+        )
+        expected = [self.configdir / "secondary", self.configdir / "secondary.testplugin"]
+        mock_setup_args.assert_called_once_with(self.parser, expected, ignore_unknown=False)
+        self.assertEqual(mock_log.info.mock_calls, [
+            call(f"Loaded config from deprecated path, see CLI docs for how to migrate: {expected[0]}"),
+            call(f"Loaded plugin config from deprecated path, see CLI docs for how to migrate: {expected[1]}")
+        ])
+
+    def test_custom_with_primary_plugin(self, mock_log):
+        mock_setup_args = self.subject(
+            [self.configdir / "primary", DeprecatedPath(self.configdir / "secondary")],
+            config=[str(self.configdir / "custom")]
+        )
+        expected = [self.configdir / "custom", self.configdir / "primary.testplugin"]
+        mock_setup_args.assert_called_once_with(self.parser, expected, ignore_unknown=False)
+        self.assertEqual(mock_log.info.mock_calls, [])
+
+    def test_custom_with_deprecated_plugin(self, mock_log):
+        mock_setup_args = self.subject(
+            [self.configdir / "non-existent", DeprecatedPath(self.configdir / "secondary")],
+            config=[str(self.configdir / "custom")]
+        )
+        expected = [self.configdir / "custom", DeprecatedPath(self.configdir / "secondary.testplugin")]
+        mock_setup_args.assert_called_once_with(self.parser, expected, ignore_unknown=False)
+        self.assertEqual(mock_log.info.mock_calls, [
+            call(f"Loaded plugin config from deprecated path, see CLI docs for how to migrate: {expected[1]}")
+        ])
+
+    def test_custom_multiple(self, mock_log):
+        mock_setup_args = self.subject(
+            [self.configdir / "primary", DeprecatedPath(self.configdir / "secondary")],
+            config=[str(self.configdir / "non-existent"), str(self.configdir / "primary"), str(self.configdir / "secondary")]
+        )
+        expected = [self.configdir / "secondary", self.configdir / "primary", self.configdir / "primary.testplugin"]
+        mock_setup_args.assert_called_once_with(self.parser, expected, ignore_unknown=False)
+        self.assertEqual(mock_log.info.mock_calls, [])
+
+
 class _TestCLIMainLogging(unittest.TestCase):
     @classmethod
     def subject(cls, argv):