Repository: streamlink/streamlink

Issue:
Environment variables for defining alternative dependencies in setup.py
As mentioned in #4113, I'd prefer having dependencies defined declaratively via setup.cfg instead of defining a list and adding it to `setup(install_requires=...)` in setup.py. Optional dependencies can be added via the `extra_requires`, but this unfortunately can't be used for defining two different sets of dependencies (exclusive-or).

https://setuptools.pypa.io/en/latest/userguide/dependency_management.html#declaring-required-dependency
https://setuptools.pypa.io/en/latest/userguide/dependency_management.html#optional-dependencies

----

**Let's take a look at the env vars in setup.py for choosing alternative dependencies:**

## STREAMLINK_USE_PYCRYPTO

The `STREAMLINK_USE_PYCRYPTO` env var and the `pycrypto` dependency was added in #713 by @mmetak due to `pycryptodome` not being in the repos of some Linux distros at that time (Arch and Fedora), but that isn't the case anymore. Everyone's switched to `pycryptodome` or `pycryptodomex` by applying patches.

This means we should remove the env var.

- **Arch Linux** (community) - 2018-08-27
  https://github.com/archlinux/svntogit-community/commit/4fa0fa3f789fbae45f8b3ecd2fee6f9450e327c6
- **Arch Linux** (AUR) - 2020-04-29
  https://aur.archlinux.org/cgit/aur.git/commit/?h=streamlink-git&id=92b1679a6616b2d89c60ba8a0e25c3b050439c17
- **Fedora** - 2019-08-20
  https://src.fedoraproject.org/rpms/python-streamlink/c/49187b8b7ce5e6836225d332a31bec5e5a30c20c?branch=rawhide
  https://src.fedoraproject.org/rpms/python-streamlink/tree/rawhide
- **Void** - 2018-06-06
  https://github.com/void-linux/void-packages/commit/0a9396a547609eeeb0972c571203beb9f23d3d80
- **Debian** / **Ubuntu** - 2019-08-23 (as always a pain to research, thanks :+1:)
  https://launchpadlibrarian.net/452638112/streamlink_1.2.0+dfsg-1~webupd8~disco2_1.3.0+dfsg-1~webupd8~disco.diff.gz
- **Gentoo** - 2017-03-05
  https://gitweb.gentoo.org/repo/gentoo.git/commit/net-misc/streamlink/streamlink-9999.ebuild?id=d377f979b7ff6cbb1185dc02b18b4770eff56a3f

## STREAMLINK_USE_PYCOUNTRY

If we remove the `STREAMLINK_USE_PYCRYPTO` env var, then `STREAMLINK_USE_PYCOUNTRY` will be the only one left for choosing alternative dependencies.

Most packages define pycountry as a dependency, but some don't. However, I think those which don't only do this because we're not using it as the default. nixos for example doesn't use it but has pycountry in its repos.

`STREAMLINK_USE_PYCOUNTRY` was added in #585 by @beardypig because of test failures on some Linux distros when using the default `iso-639` and `iso3166`.

`iso-639` however hasn't been updated anymore since April 2016, more than 5 years ago
- https://pypi.org/project/iso-639/#history
- https://pypi.org/project/iso3166/#history

`pycountry` was updated last in 2020
- https://pypi.org/project/pycountry/#history

We could default to `pycountry` and drop `iso-639`+`iso3166` if we want to remove alternative dependencies and the env var.
I think `pycountry` was made optional instead of switching dependencies back then because it's not as lightweight as `iso-639`, is that right, @beardypig ?


Discussion:
That's right. As iso-639 appears to be unmaintained we should drop it. It would tidy up the codebase as well :)
The pycrypto removal is trivial, so I've opened a PR for this here: #4174 

@beardypig if you want, please go ahead and do the same for pycountry after the pycrypto changes have been merged. Most packages are using pycountry and we should not depend on packages which are unmaintained.

Resolving patch:
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -1,5 +1,5 @@
 #!/usr/bin/env python
-from os import environ, path
+from os import path
 from sys import argv, exit, version_info
 from textwrap import dedent
 
@@ -38,23 +38,6 @@ def format_msg(text, *args, **kwargs):
     """))
 
 
-deps = [
-    "isodate",
-    "lxml>=4.6.4,<5.0",
-    "pycryptodome>=3.4.3,<4",
-    "PySocks!=1.5.7,>=1.5.6",
-    "requests>=2.26.0,<3.0",
-    "websocket-client>=1.2.1,<2.0",
-]
-
-# for localization
-if environ.get("STREAMLINK_USE_PYCOUNTRY"):
-    deps.append("pycountry")
-else:
-    deps.append("iso-639")
-    deps.append("iso3166")
-
-
 def is_wheel_for_windows():
     if "bdist_wheel" in argv:
         names = ["win32", "win-amd64", "cygwin"]
@@ -96,7 +79,6 @@ def is_wheel_for_windows():
 setup(
     version=versioneer.get_version(),
     cmdclass=versioneer.get_cmdclass(),
-    install_requires=deps,
     entry_points=entry_points,
     data_files=data_files,
 )
diff --git a/src/streamlink/utils/l10n.py b/src/streamlink/utils/l10n.py
--- a/src/streamlink/utils/l10n.py
+++ b/src/streamlink/utils/l10n.py
@@ -1,20 +1,11 @@
 import locale
 import logging
 
-
-try:
-    from iso639 import languages
-    from iso3166 import countries
-
-    PYCOUNTRY = False
-except ImportError:  # pragma: no cover
-    from pycountry import languages, countries
-
-    PYCOUNTRY = True
+from pycountry import countries, languages
 
 DEFAULT_LANGUAGE = "en"
 DEFAULT_COUNTRY = "US"
-DEFAULT_LANGUAGE_CODE = "{0}_{1}".format(DEFAULT_LANGUAGE, DEFAULT_COUNTRY)
+DEFAULT_LANGUAGE_CODE = f"{DEFAULT_LANGUAGE}_{DEFAULT_COUNTRY}"
 
 log = logging.getLogger(__name__)
 
@@ -30,14 +21,16 @@ def __init__(self, alpha2, alpha3, numeric, name, official_name=None):
     @classmethod
     def get(cls, country):
         try:
-            if PYCOUNTRY:
-                c = countries.lookup(country)
-                return Country(c.alpha_2, c.alpha_3, c.numeric, c.name, getattr(c, "official_name", c.name))
-            else:
-                c = countries.get(country)
-                return Country(c.alpha2, c.alpha3, c.numeric, c.name, c.apolitical_name)
+            c = countries.lookup(country)
+            return Country(
+                c.alpha_2,
+                c.alpha_3,
+                c.numeric,
+                c.name,
+                getattr(c, "official_name", c.name)
+            )
         except (LookupError, KeyError):
-            raise LookupError("Invalid country code: {0}".format(country))
+            raise LookupError(f"Invalid country code: {country}")
 
     def __eq__(self, other):
         return (
@@ -66,38 +59,23 @@ def __init__(self, alpha2, alpha3, name, bibliographic=None):
     @classmethod
     def get(cls, language):
         try:
-            if PYCOUNTRY:
-                lang = (languages.get(alpha_2=language)
-                        or languages.get(alpha_3=language)
-                        or languages.get(bibliographic=language)
-                        or languages.get(name=language))
-                if not lang:
-                    raise KeyError(language)
-                return Language(
-                    # some languages don't have an alpha_2 code
-                    getattr(lang, "alpha_2", ""),
-                    lang.alpha_3,
-                    lang.name,
-                    getattr(lang, "bibliographic", "")
-                )
-            else:
-                lang = None
-                if len(language) == 2:
-                    lang = languages.get(alpha2=language)
-                elif len(language) == 3:
-                    for code_type in ['part2b', 'part2t', 'part3']:
-                        try:
-                            lang = languages.get(**{code_type: language})
-                            break
-                        except KeyError:
-                            pass
-                    if not lang:
-                        raise KeyError(language)
-                else:
-                    raise KeyError(language)
-                return Language(lang.alpha2, lang.part3, lang.name, lang.part2b or lang.part2t)
+            lang = (
+                languages.get(alpha_2=language)
+                or languages.get(alpha_3=language)
+                or languages.get(bibliographic=language)
+                or languages.get(name=language)
+            )
+            if not lang:
+                raise KeyError(language)
+            return Language(
+                # some languages don't have an alpha_2 code
+                getattr(lang, "alpha_2", ""),
+                lang.alpha_3,
+                lang.name,
+                getattr(lang, "bibliographic", "")
+            )
         except (LookupError, KeyError):
-            raise LookupError("Invalid language code: {0}".format(language))
+            raise LookupError(f"Invalid language code: {language}")
 
     def __eq__(self, other):
         return (
@@ -130,7 +108,7 @@ def language_code(self):
     def _parse_locale_code(self, language_code):
         parts = language_code.split("_", 1)
         if len(parts) != 2 or len(parts[0]) != 2 or len(parts[1]) != 2:
-            raise LookupError("Invalid language code: {0}".format(language_code))
+            raise LookupError(f"Invalid language code: {language_code}")
         return self.get_language(parts[0]), self.get_country(parts[1])
 
     @language_code.setter
@@ -156,7 +134,7 @@ def language_code(self, language_code):
                 self._language_code = DEFAULT_LANGUAGE_CODE
             else:
                 raise
-        log.debug("Language code: {0}".format(self._language_code))
+        log.debug(f"Language code: {self._language_code}")
 
     def equivalent(self, language=None, country=None):
         equivalent = True


Test patch:
diff --git a/tests/utils/test_l10n.py b/tests/utils/test_l10n.py
--- a/tests/utils/test_l10n.py
+++ b/tests/utils/test_l10n.py
@@ -3,23 +3,8 @@
 
 import streamlink.utils.l10n as l10n
 
-try:
-    import iso639  # noqa: F401
-    import iso3166  # noqa: F401
 
-    ISO639 = True
-except ImportError:  # pragma: no cover
-    ISO639 = False
-
-try:
-    import pycountry  # noqa: F401
-
-    PYCOUNTRY = True
-except ImportError:  # pragma: no cover
-    PYCOUNTRY = False
-
-
-class LocalizationTestsMixin:
+class TestLocalization(unittest.TestCase):
     def test_language_code_us(self):
         locale = l10n.Localization("en_US")
         self.assertEqual("en_US", locale.language_code)
@@ -117,29 +102,6 @@ def test_language_a3_no_a2(self):
         self.assertEqual(a.name, "Desano")
         self.assertEqual(a.bibliographic, "")
 
-
-@unittest.skipIf(not ISO639, "iso639+iso3166 modules are required to test iso639+iso3166 Localization")
-class TestLocalization(LocalizationTestsMixin, unittest.TestCase):
-    def setUp(self):
-        l10n.PYCOUNTRY = False
-
-    def test_pycountry(self):
-        self.assertEqual(False, l10n.PYCOUNTRY)
-
-
-@unittest.skipIf(not PYCOUNTRY, "pycountry module required to test pycountry Localization")
-class TestLocalizationPyCountry(LocalizationTestsMixin, unittest.TestCase):
-    """Duplicate of all the Localization tests but using PyCountry instead of the iso* modules"""
-
-    def setUp(self):
-        from pycountry import languages, countries
-        l10n.countries = countries
-        l10n.languages = languages
-        l10n.PYCOUNTRY = True
-
-    def test_pycountry(self):
-        self.assertEqual(True, l10n.PYCOUNTRY)
-
     # issue #3057: generic "en" lookups via pycountry yield the "En" language, but not "English"
     def test_language_en(self):
         english_a = l10n.Localization.get_language("en")