Skip to content

cli: deprecate old config files and plugin dirs #3784

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/cli.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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``
================= ====================================================

Expand Down Expand Up @@ -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``
================= ====================================================

Expand All @@ -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``
================= ====================================================
Expand Down
8 changes: 6 additions & 2 deletions src/streamlink/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -444,12 +444,13 @@ def get_plugins(self):
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
Expand All @@ -462,12 +463,15 @@ def load_plugins(self, path):

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__
Expand Down
8 changes: 7 additions & 1 deletion src/streamlink_cli/compat.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import os
import sys
from pathlib import Path


is_darwin = sys.platform == "darwin"
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"]
16 changes: 8 additions & 8 deletions src/streamlink_cli/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
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"
Expand Down Expand Up @@ -31,7 +31,7 @@
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"
Expand All @@ -41,25 +41,25 @@
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"

Expand Down
35 changes: 24 additions & 11 deletions src/streamlink_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
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
Expand Down Expand Up @@ -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!")

Expand All @@ -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))
Expand All @@ -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

Comment on lines +667 to +679
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a small change in logic here. Previously it was trying to load multiple plugin specific config files, eg. ~/.config/streamlink/config.twitch and ~/.streamlinkrc.twitch, but since only the first non-plugin-specific default config file gets loaded (see above), plugin configs should have the same logic.

I'm also not sure if we should disable plugin config loading when --config is set or if we should add something like --no-plugin-config for disabling loading plugin configs.

if config_files:
setup_args(parser, config_files, ignore_unknown=ignore_unknown)

Expand Down
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
91 changes: 89 additions & 2 deletions tests/test_cli_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,20 @@
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

Expand Down Expand Up @@ -269,6 +272,90 @@ def test_create_output_record_and_other_file_output(self):
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):
Expand Down