Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Disuse pkg_resources in favor of importlib.metadata #1061

Merged
merged 1 commit into from
Sep 3, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 2 additions & 2 deletions lektor/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import warnings

import click
import pkg_resources

from lektor.cli_utils import AliasedGroup
from lektor.cli_utils import buildflag
Expand All @@ -15,12 +14,13 @@
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)
Expand Down
8 changes: 8 additions & 0 deletions lektor/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
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."""
Expand Down Expand Up @@ -51,3 +53,9 @@ def cleanup(self) -> None:
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
9 changes: 2 additions & 7 deletions lektor/markdown/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import sys
from typing import Any
from typing import Dict
from typing import Hashable
Expand All @@ -9,25 +8,21 @@
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
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."):
Expand Down
16 changes: 1 addition & 15 deletions lektor/packages.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
from subprocess import PIPE

import click
import pkg_resources
import requests

from lektor.utils import portable_popen
Expand Down Expand Up @@ -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
Expand All @@ -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):
Expand Down
31 changes: 24 additions & 7 deletions lektor/pluginsystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,9 @@
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

Expand Down Expand Up @@ -47,8 +46,10 @@ def env(self):

@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):
Expand Down Expand Up @@ -111,12 +112,19 @@ def to_json(self):
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
Expand Down Expand Up @@ -179,3 +187,12 @@ def emit(self, event, **kwargs):
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]
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 5 additions & 10 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -54,12 +54,12 @@ def get_cache_dir():

@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
Expand All @@ -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")
Expand Down
96 changes: 69 additions & 27 deletions tests/test_pluginsystem.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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


Expand Down Expand Up @@ -164,7 +197,7 @@ def test_path_installed_plugin_is_none(self, scratch_project):
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()
Expand Down Expand Up @@ -214,7 +247,7 @@ def test_to_json(self, dummy_plugin, dummy_plugin_distribution):
"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),
}

Expand Down Expand Up @@ -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")