Repository: streamlink/streamlink
Pull request #1827 (2018-06-19 23:44:04)

API for plugins to request input from the user
I have taken a bash at creating an API for plugins to request input from a user. I have only implemented in solution for the standard `streamlink_cli`, but I have tried to design it in a way that other front-ends can use. 

During the `Plugin.bind` call and additional (optional) parameter is passed - an instance of `UserInputRequester`, the job of which is to provide a way for the user to give an input. Implementations of `UserInputRequester` must implement two methods, `ask` and `ask_password`. `ask` is for users to input non-sensitive data, eg. username. `ask_password` is for users to input sensitive data, eg. password. For example, in the `ConsoleUserInputRequester` the two methods (`ask`, and `ask_password`) are handled by `Console.ask` and `Console.askpass`. 

For a `Plugin` to request input from the user, there are two new methods `Plugin.input_ask` and `Plugin.input_ask_password` that call `UserInputRequester.ask` and `UserInputRequester.ask_password` respectively. If no `UserInputRequester` is given and a `Plugin` tries to request user input, it will raise a `PluginError`. 

As a user of the `Streamlink` API, the `UserInputRequester` instance can set by setting the option `user-input-requester`, using `Sreamlink` options constructor parameter. It must be set when instance of `Streamlink` is created to affect built-in plugins.

I mainly created this as some way is needed for the `SteamBroadcast` plugin (#1717) to request input from the user for 2FA, and we need to maintain the disconnect between `streamlink` and `streamlink_cli`. 

codecov[bot]: # [Codecov](https://codecov.io/gh/streamlink/streamlink/pull/1827?src=pr&el=h1) Report
> Merging [#1827](https://codecov.io/gh/streamlink/streamlink/pull/1827?src=pr&el=desc) into [master](https://codecov.io/gh/streamlink/streamlink/commit/512b5f04d60cb82ce4738530ac7cc84a7476b8bb?src=pr&el=desc) will **decrease** coverage by `0.02%`.
> The diff coverage is `97.61%`.


```diff
@@            Coverage Diff             @@
##           master    #1827      +/-   ##
==========================================
- Coverage   49.94%   49.92%   -0.03%     
==========================================
  Files         235      235              
  Lines       13912    13950      +38     
==========================================
+ Hits         6949     6965      +16     
- Misses       6963     6985      +22
```

gravyboat: This looks pretty good to me @beardypig. I'd like to get @bastimeyer's feedback as well considering the scope.
Twilight0: I suppose it will able to pass username/password into plugins like zattoo as well. That's great.
beardypig: @Twilight0 you could, but if you are implementing it as part of a Kodi plugin - that is up to you. 
Twilight0: @beardypig : I was talking about the cli utility as well, ask user input at runtime instead of adding user/pass as arguments. But yeah... Kodi will definitely need some deviation and ask input with the appropriate dialog.
beardypig: @Twilight0 there is already the feature to request the username/password on the CLI - the Zattoo plugin isn't using it though (only for password, if username is given). The Zattoo plugin could be updated to require a username.

```diff
diff --git a/src/streamlink/plugins/zattoo.py b/src/streamlink/plugins/zattoo.py
index b4ae2d4..403341d 100644
--- a/src/streamlink/plugins/zattoo.py
+++ b/src/streamlink/plugins/zattoo.py
@@ -56,6 +56,7 @@ class Zattoo(Plugin):
         PluginArgument(
             "email",
             requires=["password"],
+            required=True,
             metavar="EMAIL",
             help="""
             The email associated with your zattoo account, required to access
```
back-to: your zattoo example might not work as expected,
as there is a _minor timestamp issue_ on the first login with zattoo

but yes it could be used for a relog on a failed login

it could also be used for the stations code in ustvnow
https://github.com/streamlink/streamlink/blob/master/src/streamlink/plugins/ustvnow.py#L77-L78

---

the `FatalPluginError` is a good solution
beardypig: @back-to I don't know the Zattoo plugin, but it's meant to be the same as using `get_option` - up to you if you want to do it though :) 
beardypig: I think this is good to go. @back-to, did you have any other comments? 
gravyboat: Can you rebase @beardypig? Then I can get this merged.
beardypig: @gravyboat rebase, all tests should pass :)
gravyboat: Good stuff, thanks @beardypig, and thanks for the review @back-to.

Patch:
diff --git a/src/streamlink/exceptions.py b/src/streamlink/exceptions.py
index 448005c3..52f56436 100644
--- a/src/streamlink/exceptions.py
+++ b/src/streamlink/exceptions.py
@@ -7,6 +7,16 @@ class PluginError(StreamlinkError):
     """Plugin related error."""
 
 
+class FatalPluginError(PluginError):
+    """
+    Plugin related error that cannot be recovered from
+
+    Plugin's should use this Exception when errors that can
+    never be recovered from are encountered. For example, when
+    a user's input is required an none can be given.
+    """
+
+
 class NoStreamsError(StreamlinkError):
     def __init__(self, url):
         self.url = url
diff --git a/src/streamlink/options.py b/src/streamlink/options.py
index 4c4f2cc7..5a208c16 100644
--- a/src/streamlink/options.py
+++ b/src/streamlink/options.py
@@ -38,6 +38,10 @@ class Options(object):
         if key in self.options:
             return self.options[key]
 
+    def update(self, options):
+        for key, value in options.items():
+            self.set(key, value)
+
 
 class Argument(object):
     """
diff --git a/src/streamlink/plugin/plugin.py b/src/streamlink/plugin/plugin.py
index 302f4e9e..d6613d50 100644
--- a/src/streamlink/plugin/plugin.py
+++ b/src/streamlink/plugin/plugin.py
@@ -9,9 +9,11 @@ from functools import partial
 from collections import OrderedDict
 
 from streamlink.cache import Cache
-from streamlink.exceptions import PluginError, NoStreamsError
+from streamlink.exceptions import PluginError, NoStreamsError, FatalPluginError
 from streamlink.options import Options, Arguments
 
+log = logging.getLogger(__name__)
+
 # FIXME: This is a crude attempt at making a bitrate's
 # weight end up similar to the weight of a resolution.
 # Someone who knows math, please fix.
@@ -155,6 +157,33 @@ def parse_params(params):
     return rval
 
 
+class UserInputRequester(object):
+    """
+    Base Class / Interface for requesting user input
+
+    eg. From the console
+    """
+    def ask(self, prompt):
+        """
+        Ask the user for a text input, the input is not sensitive
+        and can be echoed to the user
+
+        :param prompt: message to display when asking for the input
+        :return: the value the user input
+        """
+        raise NotImplementedError
+
+    def ask_password(self, prompt):
+        """
+        Ask the user for a text input, the input _is_ sensitive
+        and should be masked as the user gives the input
+
+        :param prompt: message to display when asking for the input
+        :return: the value the user input
+        """
+        raise NotImplementedError
+
+
 class Plugin(object):
     """A plugin can retrieve stream information from the URL specified.
 
@@ -167,14 +196,20 @@ class Plugin(object):
     options = Options()
     arguments = Arguments()
     session = None
+    _user_input_requester = None
 
     @classmethod
-    def bind(cls, session, module):
+    def bind(cls, session, module, user_input_requester=None):
         cls.cache = Cache(filename="plugin-cache.json",
                           key_prefix=module)
         cls.logger = logging.getLogger("streamlink.plugin." + module)
         cls.module = module
         cls.session = session
+        if user_input_requester is not None:
+            if isinstance(user_input_requester, UserInputRequester):
+                cls._user_input_requester = user_input_requester
+            else:
+                raise RuntimeError("user-input-requester must be an instance of UserInputRequester")
 
     def __init__(self, url):
         self.url = url
@@ -483,5 +518,26 @@ class Plugin(object):
 
         return removed
 
+    def input_ask(self, prompt):
+        if self._user_input_requester:
+            try:
+                return self._user_input_requester.ask(prompt)
+            except IOError as e:
+                raise FatalPluginError("User input error: {0}".format(e))
+            except NotImplementedError:  # ignore this and raise a FatalPluginError
+                pass
+        raise FatalPluginError("This plugin requires user input, however it is not supported on this platform")
+
+    def input_ask_password(self, prompt):
+        if self._user_input_requester:
+            try:
+                return self._user_input_requester.ask_password(prompt)
+            except IOError as e:
+                raise FatalPluginError("User input error: {0}".format(e))
+            except NotImplementedError:  # ignore this and raise a FatalPluginError
+                pass
+        raise FatalPluginError("This plugin requires user input, however it is not supported on this platform")
+
+
 
 __all__ = ["Plugin"]
diff --git a/src/streamlink/session.py b/src/streamlink/session.py
index b3acab50..79bd856d 100644
--- a/src/streamlink/session.py
+++ b/src/streamlink/session.py
@@ -44,7 +44,7 @@ class Streamlink(object):
     """A Streamlink session is used to keep track of plugins,
        options and log settings."""
 
-    def __init__(self):
+    def __init__(self, options=None):
         self.http = api.HTTPSession()
         self.options = Options({
             "hds-live-edge": 10.0,
@@ -74,13 +74,15 @@ class Streamlink(object):
             "ffmpeg-ffmpeg": None,
             "ffmpeg-video-transcode": "copy",
             "ffmpeg-audio-transcode": "copy",
-            "locale": None
+            "locale": None,
+            "user-input-requester": None
         })
+        if options:
+            self.options.update(options)
         self.plugins = {}
         self.load_builtin_plugins()
         self._logger = None
 
-
     @property
     def logger(self):
         """
@@ -232,6 +234,11 @@ class Streamlink(object):
         locale                   (str) Locale setting, in the RFC 1766 format
                                  eg. en_US or es_ES
                                  default: ``system locale``.
+
+        user-input-requester     (UserInputRequester) instance of UserInputRequester
+                                 to collect input from the user at runtime. Must be
+                                 set before the plugins are loaded.
+                                 default: ``UserInputRequester``.
         ======================== =========================================
 
         """
@@ -458,6 +465,7 @@ class Streamlink(object):
 
     def load_plugin(self, name, file, pathname, desc):
         # Set the global http session for this plugin
+        user_input_requester = self.get_option("user-input-requester")
         api.http = self.http
 
         module = imp.load_module(name, file, pathname, desc)
@@ -467,7 +475,7 @@ class Streamlink(object):
             plugin_name = module_name.split(".")[-1]  # get the plugin part of the module name
 
             plugin = getattr(module, "__plugin__")
-            plugin.bind(self, plugin_name)
+            plugin.bind(self, plugin_name, user_input_requester)
 
             if plugin.module in self.plugins:
                 log.debug("Plugin {0} is being overridden by {1}".format(plugin.module, pathname))
diff --git a/src/streamlink_cli/console.py b/src/streamlink_cli/console.py
index 9d0bb400..efa9f1ee 100644
--- a/src/streamlink_cli/console.py
+++ b/src/streamlink_cli/console.py
@@ -1,14 +1,35 @@
 import json
-import sys
 import logging
+import sys
 from getpass import getpass
 
+from streamlink.plugin.plugin import UserInputRequester
 from .compat import input
 from .utils import JSONEncoder
 
 log = logging.getLogger("streamlink.cli")
 
 
+class ConsoleUserInputRequester(UserInputRequester):
+    """
+    Request input from the user on the console using the standard ask/askpass methods
+    """
+    def __init__(self, console):
+        self.console = console
+
+    def ask(self, prompt):
+        if sys.stdin.isatty():
+            return self.console.ask(prompt.strip() + ": ")
+        else:
+            raise IOError("no TTY available")
+
+    def ask_password(self, prompt):
+        if sys.stdin.isatty():
+            return self.console.askpass(prompt.strip() + ": ")
+        else:
+            raise IOError("no TTY available")
+
+
 class ConsoleOutput(object):
     def __init__(self, output, streamlink, json=False):
         self.streamlink = streamlink
@@ -23,20 +44,24 @@ class ConsoleOutput(object):
         self.output = output
 
     def ask(self, msg, *args, **kwargs):
-        formatted = msg.format(*args, **kwargs)
-        sys.stderr.write(formatted)
+        if sys.stdin.isatty():
+            formatted = msg.format(*args, **kwargs)
+            sys.stderr.write(formatted)
 
-        try:
-            answer = input()
-        except Exception:
-            answer = ""
+            try:
+                answer = input()
+            except Exception:
+                answer = ""
 
-        return answer.strip()
+            return answer.strip()
+        else:
+            return ""
 
     def askpass(self, msg, *args, **kwargs):
-        formatted = msg.format(*args, **kwargs)
-
-        return getpass(formatted)
+        if sys.stdin.isatty():
+            return getpass(msg.format(*args, **kwargs))
+        else:
+            return ""
 
     def msg(self, msg, *args, **kwargs):
         formatted = msg.format(*args, **kwargs)
diff --git a/src/streamlink_cli/main.py b/src/streamlink_cli/main.py
index e03d70bc..dbe08d36 100644
--- a/src/streamlink_cli/main.py
+++ b/src/streamlink_cli/main.py
@@ -22,6 +22,7 @@ from streamlink import __version__ as streamlink_version
 from streamlink import (Streamlink, StreamError, PluginError,
                         NoPluginError)
 from streamlink.cache import Cache
+from streamlink.exceptions import FatalPluginError
 from streamlink.stream import StreamProcess
 from streamlink.plugins.twitch import TWITCH_CLIENT_ID
 from streamlink.plugin import PluginOptions
@@ -29,7 +30,7 @@ from streamlink.plugin import PluginOptions
 import streamlink.logger as logger
 from .argparser import build_parser
 from .compat import stdout, is_win32
-from .console import ConsoleOutput
+from .console import ConsoleOutput, ConsoleUserInputRequester
 from .constants import CONFIG_FILES, PLUGINS_DIR, STREAM_SYNONYMS
 from .output import FileOutput, PlayerOutput
 from .utils import NamedPipe, HTTPServer, ignored, progress, stream_to_url
@@ -432,6 +433,8 @@ def fetch_streams_with_retry(plugin, interval, count):
 
         try:
             streams = fetch_streams(plugin)
+        except FatalPluginError as err:
+            raise
         except PluginError as err:
             log.error(u"{0}", err)
 
@@ -737,7 +740,7 @@ def setup_streamlink():
     """Creates the Streamlink session."""
     global streamlink
 
-    streamlink = Streamlink()
+    streamlink = Streamlink({"user-input-requester": ConsoleUserInputRequester(console)})
 
 
 def setup_options():
@@ -959,6 +962,7 @@ def main():
     silent_log = any(getattr(args, attr) for attr in QUIET_OPTIONS)
     log_level = args.loglevel if not silent_log else "none"
     setup_logging(console_out, log_level)
+    setup_console(console_out)
 
     setup_streamlink()
     # load additional plugins
@@ -972,7 +976,6 @@ def main():
     log_level = args.loglevel if not silent_log else "none"
     logger.root.setLevel(log_level)
 
-    setup_console(console_out)
     setup_http_session()
     check_root()
     log_current_versions()


Test patch:
diff --git a/tests/test_options.py b/tests/test_options.py
index dd4a00ff..5b592fac 100644
--- a/tests/test_options.py
+++ b/tests/test_options.py
@@ -29,6 +29,13 @@ class TestOptions(unittest.TestCase):
         self.options.set("a_option", "option")
         self.assertEqual(self.options.get("a_option"), "option")
 
+    def test_options_update(self):
+        self.assertEqual(self.options.get("a_default"), "default")
+        self.assertEqual(self.options.get("non_existing"), None)
+
+        self.options.update({"a_option": "option"})
+        self.assertEqual(self.options.get("a_option"), "option")
+
     def test_options_name_normalised(self):
         self.assertEqual(self.options.get("a_default"), "default")
         self.assertEqual(self.options.get("a-default"), "default")
diff --git a/tests/test_plugins_input.py b/tests/test_plugins_input.py
new file mode 100644
index 00000000..07b00cde
--- /dev/null
+++ b/tests/test_plugins_input.py
@@ -0,0 +1,59 @@
+import unittest
+import os.path
+from contextlib import contextmanager
+
+from streamlink.plugin.plugin import UserInputRequester
+from tests.mock import MagicMock, patch
+
+from streamlink import Streamlink, PluginError
+from streamlink_cli.console import ConsoleUserInputRequester
+import streamlink_cli.console
+from tests.plugins.testplugin import TestPlugin as _TestPlugin
+
+
+class TestPluginUserInput(unittest.TestCase):
+    def setUp(self):
+        self.session = Streamlink()
+
+    @contextmanager
+    def _mock_console_input(self, isatty=True):
+        with patch('streamlink_cli.console.sys.stdin.isatty', return_value=isatty):
+            mock_console = MagicMock()
+            mock_console.ask.return_value = "username"
+            mock_console.askpass.return_value = "password"
+            yield ConsoleUserInputRequester(mock_console)
+
+    def test_user_input_bad_class(self):
+        p = _TestPlugin("http://example.com/stream")
+        self.assertRaises(RuntimeError, p.bind, self.session, 'test_plugin', object())
+
+    def test_user_input_not_implemented(self):
+        p = _TestPlugin("http://example.com/stream")
+        p.bind(self.session, 'test_plugin', UserInputRequester())
+        self.assertRaises(PluginError, p.input_ask, 'test')
+        self.assertRaises(PluginError, p.input_ask_password, 'test')
+
+    def test_user_input_console(self):
+        p = _TestPlugin("http://example.com/stream")
+        with self._mock_console_input() as console_input:
+            p.bind(self.session, 'test_plugin', console_input)
+            self.assertEqual("username", p.input_ask("username"))
+            self.assertEqual("password", p.input_ask_password("password"))
+            console_input.console.ask.assert_called_with("username: ")
+            console_input.console.askpass.assert_called_with("password: ")
+
+    def test_user_input_console_no_tty(self):
+        p = _TestPlugin("http://example.com/stream")
+        with self._mock_console_input(isatty=False) as console_input:
+            p.bind(self.session, 'test_plugin', console_input)
+            self.assertRaises(PluginError, p.input_ask, "username")
+            self.assertRaises(PluginError, p.input_ask_password, "password")
+
+    def test_set_via_session(self):
+        with self._mock_console_input() as console_input:
+            session = Streamlink({"user-input-requester": console_input})
+            session.load_plugins(os.path.join(os.path.dirname(__file__), "plugins"))
+
+            p = session.resolve_url("http://test.se/channel")
+            self.assertEqual("username", p.input_ask("username"))
+            self.assertEqual("password", p.input_ask_password("password"))
diff --git a/tests/test_session.py b/tests/test_session.py
index f69d7c28..de0e09f6 100644
--- a/tests/test_session.py
+++ b/tests/test_session.py
@@ -1,17 +1,7 @@
 import os
-import sys
-if sys.version_info[0:2] == (2, 6):
-    import unittest2 as unittest
-else:
-    import unittest
+import unittest
 
 from streamlink.plugin.plugin import HIGH_PRIORITY, LOW_PRIORITY
-
-try:
-    from unittest.mock import MagicMock
-except ImportError:
-    from mock import MagicMock
-
 from streamlink import Streamlink, NoPluginError
 from streamlink.plugins import Plugin
 from streamlink.stream import *