Repository: streamlink/streamlink

Issue:
Brief exit message for KeyboardInterrupt
### Checklist

- [X] This is a feature request 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 plugin requests](https://github.com/streamlink/streamlink/issues?q=is%3Aissue+label%3A%22feature+request%22)

### Description

Thanks for maintaining streamlink! It entertains me a lot. :+1: :rocket:

I'd like an option to reduce the verbosity of `KeyboardInterrupt` errors, ideally to one line at most.

I run `streamlink 2.1.1+12.g3fdd208.dirty` on Ubuntu focal as the last step of a script. Sometimes a message from an early step of that script means that I need a manual prepare step before I should start watching, so I press Ctrl+c to abort. Most of the times, this causes streamlinks's python to print about 20 lines of Traceback before the message "KeyboardInterrupt", and a useless blank line after that. Thus I have to scroll up to see the messages from my script. It would be nice to have an easy way to opt-out of that stack trace and the blank line.


Discussion:
> 2.1.1+12.g3fdd208.dirty

This version is outdated and modified, so no support for that here...

> KeyboardInterrupt

Most `KeyboardInterrupt` exceptions should be caught by `streamlink_cli`. Some of them are also caught in the `streamlink` module/package, so it depends on how you run Streamlink, which isn't clear from your description.

Could you please post the entire call stack of the `KeyboardInterrupt` exception you're seeing? This is important to know so that this can be fixed, as `SIGINT` is not supposed to print `KeyboardInterrupt` exceptions. However, what I can already tell you without looking at the code, any `KeyboardInterrupt` that gets raised before Streamlink gets properly initialized won't be caught and thus gets printed to `stderr`. This could be improved, but not fixed entirely AFAIA, unless we override the default signal behavior as the first step before doing anything else.

Any kind of error log gets written to `stderr` instead of `stdout`, so if you don't want to see that in your script, then there are ways to suppress that. `SIGTERM` should get interpreted the same as `SIGINT` (configured by `streamlink_cli`), but `SIGKILL` could also be an option if necessary, albeit a bit ugly.
Thanks for the quick feedback! I already considered piping stderr trough sed to optimize it, but I still have hopes that a python tweak will be easier.

> any KeyboardInterrupt that gets raised before Streamlink gets properly initialized won't be caught

I checked with various timings and can confirm that this is my problem. When I give it enough time to initialize, the error message is just

> Interrupted! Exiting...

Detailed log for my outdated version:

<details>

```text
$ git reset --hard 3fdd208dbeeede10d80483c3e47b5f2752d1be7f
HEAD is now at 3fdd208d plugins.nicolive: fix proxy arguments (#3710)

$ rm -- src/streamlink/plugins/{mjunoon,ustvnow}.py \
      # ^-- Avoid errors about missing libraries

$ export PYTHONPATH="$PYTHONPATH:$PWD/src"

$ CAMP='python3 -m streamlink_cli twitch.tv/basecamp'

$ timeout --signal=SIGINT 1s $CAMP
[cli][info] Found matching plugin twitch for URL twitch.tv/basecamp
Interrupted! Exiting...

$ timeout --signal=SIGINT 0.7s $CAMP
Traceback (most recent call last):
  File "/usr/lib/python3.8/runpy.py", line 194, in _run_module_as_main
    return _run_code(code, main_globals, None,
  File "/usr/lib/python3.8/runpy.py", line 87, in _run_code
    exec(code, run_globals)
  File "/tmp/streamlink-repo/src/streamlink_cli/__main__.py", line 3, in <module>
    main()
  File "/tmp/streamlink-repo/src/streamlink_cli/main.py", line 1032, in main
    setup_streamlink()
  File "/tmp/streamlink-repo/src/streamlink_cli/main.py", line 732, in setup_streamlink
    streamlink = Streamlink({"user-input-requester": ConsoleUserInputRequester(console)})
  File "/tmp/streamlink-repo/src/streamlink/session.py", line 79, in __init__
    self.load_builtin_plugins()
  File "/tmp/streamlink-repo/src/streamlink/session.py", line 445, in load_builtin_plugins
    self.load_plugins(plugins.__path__[0])
  File "/tmp/streamlink-repo/src/streamlink/session.py", line 458, in load_plugins
    mod = load_module(module_name, path)
  File "/tmp/streamlink-repo/src/streamlink/utils/__init__.py", line 24, in load_module
    spec.loader.exec_module(mod)
  File "<frozen importlib._bootstrap_external>", line 848, in exec_module
  File "<frozen importlib._bootstrap>", line 219, in _call_with_frames_removed
  File "/tmp/streamlink-repo/src/streamlink/plugins/nbc.py", line 4, in <module>
    from streamlink.plugins.theplatform import ThePlatform
  File "/tmp/streamlink-repo/src/streamlink/plugins/theplatform.py", line 10, in <module>
    class ThePlatform(Plugin):
  File "/tmp/streamlink-repo/src/streamlink/plugins/theplatform.py", line 15, in ThePlatform
    release_re = re.compile(r'''tp:releaseUrl\s*=\s*"(.*?)"''')
  File "/usr/lib/python3.8/re.py", line 252, in compile
    return _compile(pattern, flags)
  File "/usr/lib/python3.8/re.py", line 304, in _compile
    p = sre_compile.compile(pattern, flags)
  File "/usr/lib/python3.8/sre_compile.py", line 764, in compile
    p = sre_parse.parse(p, flags)
  File "/usr/lib/python3.8/sre_parse.py", line 940, in parse
    source = Tokenizer(str)
  File "/usr/lib/python3.8/sre_parse.py", line 229, in __init__
    self.decoded_string = string
KeyboardInterrupt
```

</details>

I tried to check with the current version, too, but with

```text
HEAD is now at 33ccd2eb plugins.crunchyroll: add metadata attributes (#4185)
```

it fails with

> ModuleNotFoundError: No module named 'Crypto.Util.Padding'

which, last time I tried, was too cumbersome to obtain.

The default behavior of the signal handlers can probably be changed until streamlink_cli is properly initialized. There shouldn't be a reason why we want a KeyboardInterrupt to be printed to stderr unless we're fully initialized. I will take a look at this later today.

> it fails with
> > ModuleNotFoundError: No module named 'Crypto.Util.Padding'

You are missing dependencies and have probably not installed Streamlink correctly. If a package is not available for your distro, then install it in a clean virtual environment or just use the appimage.

https://streamlink.github.io/install.html#pypi-package-and-source-code
https://streamlink.github.io/install.html#appimages

Resolving patch:
diff --git a/src/streamlink_cli/__init__.py b/src/streamlink_cli/__init__.py
--- a/src/streamlink_cli/__init__.py
+++ b/src/streamlink_cli/__init__.py
@@ -0,0 +1,12 @@
+from signal import SIGINT, SIGTERM, signal
+from sys import exit
+
+
+def _exit(*_):
+    # don't raise a KeyboardInterrupt until streamlink_cli has been fully initialized
+    exit(128 | 2)
+
+
+# override default SIGINT handler (and set SIGTERM handler) as early as possible
+signal(SIGINT, _exit)
+signal(SIGTERM, _exit)
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
@@ -685,7 +685,9 @@ def setup_config_args(parser, ignore_unknown=False):
 
 
 def setup_signals():
-    # Handle SIGTERM just like SIGINT
+    # restore default behavior of raising a KeyboardInterrupt on SIGINT (and SIGTERM)
+    # so cleanup code can be run when the user stops execution
+    signal.signal(signal.SIGINT, signal.default_int_handler)
     signal.signal(signal.SIGTERM, signal.default_int_handler)
 
 
@@ -1005,8 +1007,6 @@ def main():
     log_file = args.logfile if log_level != "none" else None
     setup_logger_and_console(console_out, log_file, log_level, args.json)
 
-    setup_signals()
-
     setup_streamlink()
     # load additional plugins
     setup_plugins(args.plugin_dirs)
@@ -1025,6 +1025,8 @@ def main():
     log_current_versions()
     log_current_arguments(streamlink, parser)
 
+    setup_signals()
+
     if args.version_check or args.auto_version_check:
         with ignored(Exception):
             check_version(force=args.version_check)


Test patch:
diff --git a/tests/__init__.py b/tests/__init__.py
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -1,8 +1,18 @@
 import os
+import signal
 import warnings
 
 import pytest
 
+# import streamlink_cli as early as possible to execute its default signal overrides
+# noinspection PyUnresolvedReferences
+import streamlink_cli  # noqa: F401
+
+
+# immediately restore default signal handlers for the test runner
+signal.signal(signal.SIGINT, signal.default_int_handler)
+signal.signal(signal.SIGTERM, signal.default_int_handler)
+
 
 def catch_warnings(record=False, module=None):
     def _catch_warnings_wrapper(f):
diff --git a/tests/test_cli_main.py b/tests/test_cli_main.py
--- a/tests/test_cli_main.py
+++ b/tests/test_cli_main.py
@@ -20,7 +20,6 @@
     format_valid_streams,
     handle_stream,
     handle_url,
-    log_current_arguments,
     resolve_stream_name,
     setup_config_args
 )
@@ -500,14 +499,13 @@ def subject(cls, argv):
         session = Streamlink()
         session.load_plugins(os.path.join(os.path.dirname(__file__), "plugin"))
 
-        def _log_current_arguments(*args, **kwargs):
-            log_current_arguments(*args, **kwargs)
-            raise SystemExit
+        # stop test execution at the setup_signals() call, as we're not interested in what comes afterwards
+        class StopTest(Exception):
+            pass
 
         with patch("streamlink_cli.main.streamlink", session), \
-             patch("streamlink_cli.main.log_current_arguments", side_effect=_log_current_arguments), \
+             patch("streamlink_cli.main.setup_signals", side_effect=StopTest), \
              patch("streamlink_cli.main.CONFIG_FILES", []), \
-             patch("streamlink_cli.main.setup_signals"), \
              patch("streamlink_cli.main.setup_streamlink"), \
              patch("streamlink_cli.main.setup_plugins"), \
              patch("streamlink_cli.main.setup_http_session"), \
@@ -516,7 +514,7 @@ def _log_current_arguments(*args, **kwargs):
             mock_argv.__getitem__.side_effect = lambda x: argv[x]
             try:
                 streamlink_cli.main.main()
-            except SystemExit:
+            except StopTest:
                 pass
 
     def tearDown(self):