From: jan iversen <jancasacondor@gmail.com>
Date: Fri, 4 Aug 2023 17:02:48 +0200
Subject: Replace pkg_resources with importlib.metadata / import_module.
 (#2104)

* Remove use of pkg_resources.

* update tests.

Origin: backport, https://github.com/mopidy/mopidy/commit/136c6f435eafecb56d6b56d970db12c098cd88a2
Bug-Debian: https://bugs.debian.org/1083483
Last-Update: 2026-02-03
---
 mopidy/__init__.py          |  5 ++---
 mopidy/ext.py               | 40 +++++++++++++++---------------------
 mopidy/internal/deps.py     | 37 ++++++++++++++++++---------------
 tests/internal/test_deps.py | 46 +++++++++++++++++++----------------------
 tests/test_ext.py           | 50 ++++++++++++++++++++-------------------------
 5 files changed, 82 insertions(+), 96 deletions(-)

diff --git a/mopidy/__init__.py b/mopidy/__init__.py
index ccb8082..a49df45 100644
--- a/mopidy/__init__.py
+++ b/mopidy/__init__.py
@@ -1,8 +1,7 @@
 import platform
 import sys
 import warnings
-
-import pkg_resources
+from importlib.metadata import version
 
 if not sys.version_info >= (3, 7):
     sys.exit(
@@ -12,4 +11,4 @@ if not sys.version_info >= (3, 7):
 
 warnings.filterwarnings("ignore", "could not open display")
 
-__version__ = pkg_resources.get_distribution("Mopidy").version
+__version__ = version("Mopidy")
diff --git a/mopidy/ext.py b/mopidy/ext.py
index a2f7799..27fb844 100644
--- a/mopidy/ext.py
+++ b/mopidy/ext.py
@@ -2,10 +2,9 @@ from __future__ import annotations
 
 import logging
 from collections.abc import Mapping
+from importlib import metadata
 from typing import TYPE_CHECKING, NamedTuple
 
-import pkg_resources
-
 from mopidy import config as config_lib
 from mopidy import exceptions
 from mopidy.internal import path
@@ -73,6 +72,12 @@ class Extension:
         schema["enabled"] = config_lib.Boolean()
         return schema
 
+    @classmethod
+    def check_attr(cls) -> None:
+        """Check if ext_name exist."""
+        if not hasattr(cls, "ext_name") or cls.ext_name is None:
+            raise AttributeError(f"{cls} not an extension or ext_name missing!")
+
     @classmethod
     def get_cache_dir(cls, config: Config) -> Path:
         """Get or create cache directory for the extension.
@@ -215,13 +220,13 @@ def load_extensions() -> List[ExtensionData]:
 
     installed_extensions = []
 
-    for entry_point in pkg_resources.iter_entry_points("mopidy.ext"):
+    for entry_point in metadata.entry_points(group="mopidy.ext"):
         logger.debug("Loading entry point: %s", entry_point)
         try:
-            extension_class = entry_point.resolve()
-        except Exception as e:
+            extension_class = entry_point.load()
+        except Exception as exc:
             logger.exception(
-                f"Failed to load extension {entry_point.name}: {e}"
+                f"Failed to load extension {entry_point.name}: {exc}"
             )
             continue
 
@@ -286,28 +291,15 @@ def validate_extension_data(data: ExtensionData) -> bool:
         return False
 
     try:
-        data.entry_point.require()
-    except pkg_resources.DistributionNotFound as exc:
+        data.entry_point.load()
+    except ModuleNotFoundError as exc:
         logger.info(
-            "Disabled extension %s: Dependency %s not found",
+            "Disabled extension %s: Exception %s",
             data.extension.ext_name,
             exc,
         )
-        return False
-    except pkg_resources.VersionConflict as exc:
-        if len(exc.args) == 2:
-            found, required = exc.args
-            logger.info(
-                "Disabled extension %s: %s required, but found %s at %s",
-                data.extension.ext_name,
-                required,
-                found,
-                found.location,
-            )
-        else:
-            logger.info(
-                "Disabled extension %s: %s", data.extension.ext_name, exc
-            )
+        # Remark: There are no version check, so any version is accepted
+        # this is a difference to pkg_resources, and affect debugging.
         return False
 
     try:
diff --git a/mopidy/internal/deps.py b/mopidy/internal/deps.py
index c8b53a4..afd5a1b 100644
--- a/mopidy/internal/deps.py
+++ b/mopidy/internal/deps.py
@@ -1,9 +1,9 @@
 import functools
 import os
 import platform
+import re
 import sys
-
-import pkg_resources
+from importlib import metadata
 
 from mopidy.internal import formatting
 from mopidy.internal.gi import Gst, gi
@@ -12,9 +12,9 @@ from mopidy.internal.gi import Gst, gi
 def format_dependency_list(adapters=None):
     if adapters is None:
         dist_names = {
-            ep.dist.project_name
-            for ep in pkg_resources.iter_entry_points("mopidy.ext")
-            if ep.dist.project_name != "Mopidy"
+            ep.dist.name
+            for ep in metadata.entry_points(group="mopidy.ext")
+            if ep.dist.name != "Mopidy"
         }
         dist_infos = [
             functools.partial(pkg_info, dist_name) for dist_name in dist_names
@@ -87,25 +87,30 @@ def pkg_info(
     if project_name is None:
         project_name = "Mopidy"
     try:
-        distribution = pkg_resources.get_distribution(project_name)
-        extras = include_extras and distribution.extras or []
-        if include_transitive_deps:
-            dependencies = [
-                pkg_info(
-                    d.project_name,
-                    include_transitive_deps=d.project_name != "Mopidy",
+        distribution = metadata.distribution(project_name)
+        if include_transitive_deps and distribution.requires:
+            dependencies = []
+            for raw in distribution.requires:
+                if not include_extras and "extra" in raw:
+                    continue
+                entry = re.match(
+                    "[a-zA-Z0-9_']+", raw
+                ).group()  # pyright: ignore[reportOptionalMemberAccess]
+                dependencies.append(
+                    pkg_info(
+                        entry,
+                        include_transitive_deps=entry != "Mopidy",
+                    )
                 )
-                for d in distribution.requires(extras)
-            ]
         else:
             dependencies = []
         return {
             "name": project_name,
             "version": distribution.version,
-            "path": distribution.location,
+            "path": str(distribution.locate_file(".")),
             "dependencies": dependencies,
         }
-    except pkg_resources.ResolutionError:
+    except metadata.PackageNotFoundError:
         return {
             "name": project_name,
         }
diff --git a/tests/internal/test_deps.py b/tests/internal/test_deps.py
index e30e95b..b7bd935 100644
--- a/tests/internal/test_deps.py
+++ b/tests/internal/test_deps.py
@@ -1,10 +1,10 @@
 import platform
 import sys
 import unittest
+from importlib import metadata
+from pathlib import Path
 from unittest import mock
 
-import pkg_resources
-
 from mopidy.internal import deps
 from mopidy.internal.gi import Gst, gi
 
@@ -79,25 +79,31 @@ class DepsTest(unittest.TestCase):
         assert gi.__version__ in result["other"]
         assert "Relevant elements:" in result["other"]
 
-    @mock.patch("pkg_resources.get_distribution")
+    @mock.patch("importlib.metadata.distribution")
     def test_pkg_info(self, get_distribution_mock):
-        dist_setuptools = mock.Mock()
-        dist_setuptools.project_name = "setuptools"
+        dist_setuptools = mock.MagicMock()
+        dist_setuptools.name = "setuptools"
         dist_setuptools.version = "0.6"
-        dist_setuptools.location = "/tmp/example/setuptools"
-        dist_setuptools.requires.return_value = []
+        dist_setuptools.locate_file = mock.MagicMock(
+            return_value=Path("/tmp/example/setuptools/main.py")
+        )
+        dist_setuptools.requires = []
 
         dist_pykka = mock.Mock()
-        dist_pykka.project_name = "Pykka"
+        dist_pykka.name = "Pykka"
         dist_pykka.version = "1.1"
-        dist_pykka.location = "/tmp/example/pykka"
-        dist_pykka.requires.return_value = [dist_setuptools]
+        dist_pykka.locate_file = mock.MagicMock(
+            return_value=Path("/tmp/example/pykka/main.py")
+        )
+        dist_pykka.requires = [f"{dist_setuptools.name}==0.6"]
 
         dist_mopidy = mock.Mock()
-        dist_mopidy.project_name = "Mopidy"
+        dist_mopidy.name = "Mopidy"
         dist_mopidy.version = "0.13"
-        dist_mopidy.location = "/tmp/example/mopidy"
-        dist_mopidy.requires.return_value = [dist_pykka]
+        dist_mopidy.locate_file = mock.MagicMock(
+            return_value=Path("/tmp/example/mopidy/no_name.py")
+        )
+        dist_mopidy.requires = [f"{dist_pykka.name}==1.1"]
 
         get_distribution_mock.side_effect = [
             dist_mopidy,
@@ -119,19 +125,9 @@ class DepsTest(unittest.TestCase):
         assert "setuptools" == dep_info_setuptools["name"]
         assert "0.6" == dep_info_setuptools["version"]
 
-    @mock.patch("pkg_resources.get_distribution")
+    @mock.patch("importlib.metadata.distribution")
     def test_pkg_info_for_missing_dist(self, get_distribution_mock):
-        get_distribution_mock.side_effect = pkg_resources.DistributionNotFound
-
-        result = deps.pkg_info()
-
-        assert "Mopidy" == result["name"]
-        assert "version" not in result
-        assert "path" not in result
-
-    @mock.patch("pkg_resources.get_distribution")
-    def test_pkg_info_for_wrong_dist_version(self, get_distribution_mock):
-        get_distribution_mock.side_effect = pkg_resources.VersionConflict
+        get_distribution_mock.side_effect = metadata.PackageNotFoundError("test")
 
         result = deps.pkg_info()
 
diff --git a/tests/test_ext.py b/tests/test_ext.py
index afba70a..8204b13 100644
--- a/tests/test_ext.py
+++ b/tests/test_ext.py
@@ -1,7 +1,7 @@
 import pathlib
+from importlib import metadata
 from unittest import mock
 
-import pkg_resources
 import pytest
 
 from mopidy import config, exceptions, ext
@@ -74,7 +74,7 @@ class TestExtension:
 class TestLoadExtensions:
     @pytest.fixture
     def iter_entry_points_mock(self, request):
-        patcher = mock.patch("pkg_resources.iter_entry_points")
+        patcher = mock.patch("importlib.metadata.entry_points")
         iter_entry_points = patcher.start()
         iter_entry_points.return_value = []
         yield iter_entry_points
@@ -86,7 +86,7 @@ class TestLoadExtensions:
 
     def test_load_extensions(self, iter_entry_points_mock):
         mock_entry_point = mock.Mock()
-        mock_entry_point.resolve.return_value = DummyExtension
+        mock_entry_point.load.return_value = DummyExtension
 
         iter_entry_points_mock.return_value = [mock_entry_point]
 
@@ -105,7 +105,7 @@ class TestLoadExtensions:
             pass
 
         mock_entry_point = mock.Mock()
-        mock_entry_point.resolve.return_value = WrongClass
+        mock_entry_point.load.return_value = WrongClass
 
         iter_entry_points_mock.return_value = [mock_entry_point]
 
@@ -113,18 +113,15 @@ class TestLoadExtensions:
 
     def test_gets_instance(self, iter_entry_points_mock):
         mock_entry_point = mock.Mock()
-        mock_entry_point.resolve.return_value = DummyExtension()
+        mock_entry_point.load.return_value = DummyExtension()
 
         iter_entry_points_mock.return_value = [mock_entry_point]
 
         assert ext.load_extensions() == []
 
     def test_creating_instance_fails(self, iter_entry_points_mock):
-        mock_extension = mock.Mock(spec=ext.Extension)
-        mock_extension.side_effect = Exception
-
         mock_entry_point = mock.Mock()
-        mock_entry_point.resolve.return_value = mock_extension
+        mock_entry_point.load.return_value = mock.Mock(side_effect=Exception)
 
         iter_entry_points_mock.return_value = [mock_entry_point]
 
@@ -132,7 +129,7 @@ class TestLoadExtensions:
 
     def test_get_config_schema_fails(self, iter_entry_points_mock):
         mock_entry_point = mock.Mock()
-        mock_entry_point.resolve.return_value = DummyExtension
+        mock_entry_point.load.return_value = DummyExtension
 
         iter_entry_points_mock.return_value = [mock_entry_point]
 
@@ -144,7 +141,7 @@ class TestLoadExtensions:
 
     def test_get_default_config_fails(self, iter_entry_points_mock):
         mock_entry_point = mock.Mock()
-        mock_entry_point.resolve.return_value = DummyExtension
+        mock_entry_point.load.return_value = DummyExtension
 
         iter_entry_points_mock.return_value = [mock_entry_point]
 
@@ -156,7 +153,7 @@ class TestLoadExtensions:
 
     def test_get_command_fails(self, iter_entry_points_mock):
         mock_entry_point = mock.Mock()
-        mock_entry_point.resolve.return_value = DummyExtension
+        mock_entry_point.load.return_value = DummyExtension
 
         iter_entry_points_mock.return_value = [mock_entry_point]
 
@@ -171,34 +168,31 @@ class TestValidateExtensionData:
     @pytest.fixture
     def ext_data(self):
         extension = DummyExtension()
-
         entry_point = mock.Mock()
         entry_point.name = extension.ext_name
-
-        schema = extension.get_config_schema()
-        defaults = extension.get_default_config()
-        command = extension.get_command()
-
-        return ext.ExtensionData(
-            extension, entry_point, schema, defaults, command
+        yield ext.ExtensionData(
+            extension,
+            entry_point,
+            extension.get_config_schema(),
+            extension.get_default_config(),
+            extension.get_command(),
         )
 
+    def test_real(self):
+        for dist in ext.load_extensions():
+            assert ext.validate_extension_data(dist)
+
     def test_name_mismatch(self, ext_data):
         ext_data.entry_point.name = "barfoo"
         assert not ext.validate_extension_data(ext_data)
 
     def test_distribution_not_found(self, ext_data):
-        error = pkg_resources.DistributionNotFound
-        ext_data.entry_point.require.side_effect = error
-        assert not ext.validate_extension_data(ext_data)
-
-    def test_version_conflict(self, ext_data):
-        error = pkg_resources.VersionConflict
-        ext_data.entry_point.require.side_effect = error
+        error = metadata.PackageNotFoundError
+        ext_data.entry_point.load.side_effect = error
         assert not ext.validate_extension_data(ext_data)
 
     def test_entry_point_require_exception(self, ext_data):
-        ext_data.entry_point.require.side_effect = Exception(
+        ext_data.entry_point.load.side_effect = Exception(
             "Some extension error"
         )
 
