Repository: lektor/lektor
Pull request #1061 (2022-09-02 02:01:18)

Disuse pkg_resources in favor of importlib.metadata
Pkg_resources is deprecated. Importlib.metadata is the new standard way of dealing with distribution metadata: entry points, versions, etc.

This PR eliminates our usage of `pkg_resources` in favor of `importlib.metadata` (or `importlib_metadata` for python < 3.10 — in this PR we use `importlib.metadata.packages_distributions()` which is only available in 3.10.)



### Issue(s) Resolved

This would have sort of fixed the issue noted in [#1058](https://github.com/lektor/lektor/issues/1058#issuecomment-1234619566) having to do with the misspelling of a dependency.  (The real problem there was the misspelling; however, using the same distribution grokking machinery as python itself uses helps minimize weirdness due to differences in behavior between the import system and the introspection system.)



### Related Issues / Links

<!---
Are there any similar or related issues or pull requests?
Did you make a pull request to update the docs?
--->

### Description of Changes

- [x] Wrote at least one-line docstrings (for any new functions)
- [x] Added unit test(s) covering the changes (if testable)


<!--- Explain what you've done and why --->

<!--- Thanks for your help making Lektor better for everyone! --->



Patch:
diff --git a/CHANGES.md b/CHANGES.md
index 885af70..6464c67 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -2,12 +2,6 @@
 
 These are all the changes in Lektor since the first public release.
 
-## 3.4.0b3 (unreleased)
-
-### Bugs
-
-- Fix spelling of `typing-inspect` in dependencies. ([#1058][], continued)
-
 ## 3.4.0b2 (2022-08-31)
 
 ### Bugs
diff --git a/lektor/cli.py b/lektor/cli.py
index 23d6573..cdbecef 100644
--- a/lektor/cli.py
+++ b/lektor/cli.py
@@ -6,7 +6,6 @@ import time
 import warnings
 
 import click
-import pkg_resources
 
 from lektor.cli_utils import AliasedGroup
 from lektor.cli_utils import buildflag
@@ -15,12 +14,13 @@ from lektor.cli_utils import extraflag
 from lektor.cli_utils import pass_context
 from lektor.cli_utils import pruneflag
 from lektor.cli_utils import validate_language
+from lektor.compat import importlib_metadata as metadata
 from lektor.project import Project
 from lektor.utils import profile_func
 from lektor.utils import secure_url
 
 
-version = pkg_resources.get_distribution("Lektor").version  # pylint: disable=no-member
+version = metadata.version("Lektor")
 
 
 @click.group(cls=AliasedGroup)
diff --git a/lektor/compat.py b/lektor/compat.py
index 2c1ee37..95c61fe 100644
--- a/lektor/compat.py
+++ b/lektor/compat.py
@@ -5,6 +5,8 @@ import tempfile
 from functools import partial
 from itertools import chain
 
+__all__ = ["TemporaryDirectory", "importlib_metadata"]
+
 
 def _ensure_tree_writeable(path: str) -> None:
     """Attempt to ensure that all files in the tree rooted at path are writeable."""
@@ -51,3 +53,9 @@ if sys.version_info >= (3, 8):
     TemporaryDirectory = tempfile.TemporaryDirectory
 else:
     TemporaryDirectory = FixedTemporaryDirectory
+
+if sys.version_info >= (3, 10):
+    from importlib import metadata as importlib_metadata
+else:
+    # we use importlib.metadata.packages_distributions() which is new in python 3.10
+    import importlib_metadata
diff --git a/lektor/markdown/__init__.py b/lektor/markdown/__init__.py
index f719fd3..17e50e7 100644
--- a/lektor/markdown/__init__.py
+++ b/lektor/markdown/__init__.py
@@ -1,4 +1,3 @@
-import sys
 from typing import Any
 from typing import Dict
 from typing import Hashable
@@ -9,6 +8,7 @@ from weakref import ref as weakref
 from deprecated import deprecated
 from markupsafe import Markup
 
+from lektor.compat import importlib_metadata as metadata
 from lektor.markdown.controller import ControllerCache
 from lektor.markdown.controller import FieldOptions
 from lektor.markdown.controller import MarkdownController
@@ -16,18 +16,13 @@ from lektor.markdown.controller import Meta
 from lektor.markdown.controller import RenderResult
 from lektor.sourceobj import SourceObject
 
-if sys.version_info >= (3, 8):
-    from importlib.metadata import version
-else:
-    from importlib_metadata import version
-
 if TYPE_CHECKING:  # pragma: no cover
     from lektor.environment import Environment
 
 
 controller_class: Type[MarkdownController]
 
-MISTUNE_VERSION = version("mistune")
+MISTUNE_VERSION = metadata.version("mistune")
 if MISTUNE_VERSION.startswith("0."):
     from lektor.markdown.mistune0 import MarkdownController0 as controller_class
 elif MISTUNE_VERSION.startswith("2."):
diff --git a/lektor/packages.py b/lektor/packages.py
index 6085cfc..33686fa 100644
--- a/lektor/packages.py
+++ b/lektor/packages.py
@@ -7,7 +7,6 @@ import tempfile
 from subprocess import PIPE
 
 import click
-import pkg_resources
 import requests
 
 from lektor.utils import portable_popen
@@ -296,19 +295,6 @@ def update_cache(package_root, remote_packages, local_package_path, refresh=Fals
         write_manifest(manifest_file, all_packages)
 
 
-def add_site(path):
-    """This adds a path to as proper site packages to all associated import
-    systems.  Primarily it invokes `site.addsitedir` and also configures
-    pkg_resources' metadata accordingly.
-    """
-    site.addsitedir(path)
-    ws = pkg_resources.working_set
-    ws.entry_keys.setdefault(path, [])
-    ws.entries.append(path)
-    for dist in pkg_resources.find_distributions(path, False):
-        ws.add(dist, path, insert=True)
-
-
 def load_packages(env, reinstall=False):
     """This loads all the packages of a project.  What this does is updating
     the current cache in ``root/package-cache`` and then add the Python
@@ -326,7 +312,7 @@ def load_packages(env, reinstall=False):
         os.path.join(env.root_path, "packages"),
         refresh=reinstall,
     )
-    add_site(package_root)
+    site.addsitedir(package_root)
 
 
 def wipe_package_cache(env):
diff --git a/lektor/pluginsystem.py b/lektor/pluginsystem.py
index 41f573b..090af97 100644
--- a/lektor/pluginsystem.py
+++ b/lektor/pluginsystem.py
@@ -3,10 +3,9 @@ import sys
 import warnings
 from weakref import ref as weakref
 
-import pkg_resources
 from inifile import IniFile
-from pkg_resources import get_distribution
 
+from lektor.compat import importlib_metadata as metadata
 from lektor.context import get_ctx
 from lektor.utils import process_extra_flags
 
@@ -47,8 +46,10 @@ class Plugin:
 
     @property
     def version(self):
-
-        return get_distribution("lektor-" + self.id).version
+        try:
+            return metadata.version(_dist_name_for_module(self.__module__))
+        except LookupError:
+            return None
 
     @property
     def path(self):
@@ -111,12 +112,19 @@ class Plugin:
 def load_plugins():
     """Loads all available plugins and returns them."""
     rv = {}
-    for ep in pkg_resources.iter_entry_points("lektor.plugins"):
+    for ep in metadata.entry_points(  # pylint: disable=unexpected-keyword-arg
+        group="lektor.plugins"
+    ):
+        # XXX: do we really need to be so strict about distribution names?
         match_name = "lektor-" + ep.name.lower()
-        if match_name != ep.dist.project_name.lower():
+        try:
+            dist_name = _dist_name_for_module(ep.module)
+        except LookupError:
+            dist_name = ""
+        if match_name != dist_name.lower():
             raise RuntimeError(
                 "Disallowed distribution name: distribution name for "
-                f"plugin {ep.name!r} must be {match_name!r}."
+                f"plugin {ep.name!r} must be {match_name!r} (not {dist_name!r})."
             )
         rv[ep.name] = ep.load()
     return rv
@@ -179,3 +187,12 @@ class PluginController:
                         DeprecationWarning,
                     )
         return rv
+
+
+def _dist_name_for_module(module: str) -> "str | None":
+    """Return the name of the distribution which provides the named module."""
+    top_level = module.partition(".")[0]
+    distributions = metadata.packages_distributions().get(top_level, [])
+    if len(distributions) != 1:
+        raise LookupError(f"Can not find distribution for {module}")
+    return distributions[0]
diff --git a/setup.cfg b/setup.cfg
index 68a91ec..5924625 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -40,7 +40,7 @@ install_requires =
     EXIFRead
     filetype>=1.0.7
     Flask
-    importlib_metadata;python_version<"3.8"
+    importlib_metadata; python_version<"3.10"
     inifile>=0.4.1
     Jinja2>=3.0
     MarkupSafe
@@ -56,7 +56,7 @@ install_requires =
     # by latest typing.inspect.
     #
     # See https://github.com/lovasoa/marshmallow_dataclass/issues/206
-    typing-inspect < 0.8.0
+    typing.inspect < 0.8.0
     watchdog
     Werkzeug>=2.1.0,<3
 


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 262998f..c49636c 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,10 +1,10 @@
+import importlib
 import os
 import shutil
 import sys
 import textwrap
 from pathlib import Path
 
-import pkg_resources
 import pytest
 from _pytest.monkeypatch import MonkeyPatch
 
@@ -54,12 +54,12 @@ def temporary_lektor_cache(tmp_path_factory):
 
 @pytest.fixture
 def save_sys_path(monkeypatch):
-    """Save `sys.path`, `sys.modules`, and `pkg_resources` state on test
+    """Save `sys.path`, and `sys.modules` state on test
     entry, restore after test completion.
 
     Any test which constructs a `lektor.environment.Environment` instance
     or which runs any of the Lektor CLI commands should use this fixture
-    to ensure that alternations made to `sys.path` do not interfere with
+    to ensure that alterations made to `sys.path` do not interfere with
     other tests.
 
     Lektor's private package cache is added to `sys.path` by
@@ -79,13 +79,8 @@ def save_sys_path(monkeypatch):
     # numerous ways that a reference to a loaded module may still be held.
     monkeypatch.setattr(sys, "modules", sys.modules.copy())
 
-    # While pkg_resources.__getstate__ and pkg_resources.__setstate__
-    # do not appear to be a documented part of the pkg_resources API,
-    # they are used in setuptools' own tests, and appear to have been
-    # a stable feature since 2011.
-    saved_state = pkg_resources.__getstate__()
-    yield
-    pkg_resources.__setstate__(saved_state)
+    # It's not clear that this is necessary, but it probably won't hurt.
+    importlib.invalidate_caches()
 
 
 @pytest.fixture(scope="function")
diff --git a/tests/test_pluginsystem.py b/tests/test_pluginsystem.py
index be44c08..a061f98 100644
--- a/tests/test_pluginsystem.py
+++ b/tests/test_pluginsystem.py
@@ -1,14 +1,19 @@
 """Unit tests for lektor.pluginsystem.
 """
+import inspect
+import sys
+from importlib.abc import Loader
+from importlib.machinery import ModuleSpec
 from pathlib import Path
 from unittest import mock
 
-import pkg_resources
 import pytest
 
 from lektor.cli import cli
+from lektor.compat import importlib_metadata as metadata
 from lektor.context import Context
 from lektor.packages import add_package_to_project
+from lektor.pluginsystem import _dist_name_for_module
 from lektor.pluginsystem import get_plugin
 from lektor.pluginsystem import load_plugins
 from lektor.pluginsystem import Plugin
@@ -51,24 +56,54 @@ def dummy_plugin_calls(monkeypatch):
     return DummyPlugin.calls
 
 
-class DummyEntryPointMetadata:
-    """Implement enough of `pkg_resources.IMetadataProvider` to convince a
-    Distribution that it has an entry point.
-    """
+class DummyDistribution(metadata.Distribution):
 
+    _files = {
+        "top_level.txt": f"{__name__}\n",
+        "entry_points.txt": inspect.cleandoc(
+            f"""
+            [lektor.plugins]
+            dummy-plugin = {__name__}:DummyPlugin
+            """
+        ),
+    }
+
+    # Allow overriding inherited properties with class attributes
+    metadata = None
+
+    def __init__(self, metadata):
+        self.metadata = metadata
+
+    def read_text(self, filename):
+        return self._files.get(filename)
+
+    def locate_file(self, path):  # pylint: disable=no-self-use
+        return None
+
+
+class DummyPluginLoader(Loader):
+    # pylint: disable=abstract-method
     # pylint: disable=no-self-use
 
-    def __init__(self, entry_points_txt):
-        self.entry_points_txt = entry_points_txt
+    def create_module(self, spec):
+        return None
+
+    def exec_module(self, module):
+        setattr(module, "DummyPlugin", DummyPlugin)
 
-    def has_metadata(self, name):
-        return name == "entry_points.txt"
 
-    def get_metadata(self, name):
-        return self.entry_points_txt if name == "entry_points.txt" else ""
+class DummyPluginFinder(metadata.DistributionFinder):
+    def __init__(self, module: str, distribution: metadata.Distribution):
+        self.module = module
+        self.distribution = distribution
 
-    def get_metadata_lines(self, name):
-        return pkg_resources.yield_lines(self.get_metadata(name))
+    def find_spec(self, fullname, path, target=None):
+        if fullname == self.module and path is None:
+            return ModuleSpec(fullname, DummyPluginLoader())
+        return None
+
+    def find_distributions(self, context=metadata.DistributionFinder.Context()):
+        return [self.distribution]
 
 
 @pytest.fixture
@@ -77,20 +112,18 @@ def dummy_plugin_distribution_name():
 
 
 @pytest.fixture
-def dummy_plugin_distribution(dummy_plugin_distribution_name, save_sys_path):
+def dummy_plugin_distribution(
+    dummy_plugin_distribution_name, save_sys_path, monkeypatch
+):
     """Add a dummy plugin distribution to the current working_set."""
-    dist = pkg_resources.Distribution(
-        project_name=dummy_plugin_distribution_name,
-        metadata=DummyEntryPointMetadata(
-            f"""
-            [lektor.plugins]
-            dummy-plugin = {__name__}:DummyPlugin
-            """
-        ),
-        version="1.23",
-        location=__file__,
+    dist = DummyDistribution(
+        {
+            "Name": dummy_plugin_distribution_name,
+            "Version": "1.23",
+        }
     )
-    pkg_resources.working_set.add(dist)
+    finder = DummyPluginFinder(__name__, dist)
+    monkeypatch.setattr("sys.meta_path", [finder] + sys.meta_path)
     return dist
 
 
@@ -164,7 +197,7 @@ class TestPlugin:
         assert plugin.path is None
 
     def test_import_name(self, dummy_plugin):
-        assert dummy_plugin.import_name == "test_pluginsystem:DummyPlugin"
+        assert dummy_plugin.import_name == f"{__name__}:DummyPlugin"
 
     def test_get_lektor_config(self, dummy_plugin):
         cfg = dummy_plugin.get_lektor_config()
@@ -214,7 +247,7 @@ class TestPlugin:
             "name": DummyPlugin.name,
             "description": DummyPlugin.description,
             "version": dummy_plugin_distribution.version,
-            "import_name": "test_pluginsystem:DummyPlugin",
+            "import_name": f"{__name__}:DummyPlugin",
             "path": str(Path(__file__).parent),
         }
 
@@ -295,3 +328,12 @@ def test_cli_integration(project, cli_runner, monkeypatch):
     )
     for call in DummyPlugin.calls:
         assert call["extra_flags"] == {"flag1": "flag1", "flag2": "value2"}
+
+
+def test_dist_name_form_module():
+    assert _dist_name_for_module("lektor.db") == "Lektor"
+
+
+def test_dist_name_form_module_raises_exception():
+    with pytest.raises(LookupError):
+        assert _dist_name_for_module("missing.module")