diff --git a/docs/source/plugin.rst b/docs/source/plugin.rst index c8422fbb42..fbbf60486d 100644 --- a/docs/source/plugin.rst +++ b/docs/source/plugin.rst @@ -125,7 +125,7 @@ Plugin glossary Entry point plugin A plugin that is an installed Python package and exposed through the - ``sopel.plugins`` setuptools entry point. + ``sopel.plugins`` entry point group. Sopelunking Action performed by a :term:`Sopelunker`. diff --git a/requirements.txt b/requirements.txt index 9937ec9c3b..f2492374ae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,5 @@ geoip2>=4.0,<5.0 requests>=2.24.0,<3.0.0 dnspython<3.0 sqlalchemy>=1.4,<1.5 +importlib_metadata>=3.6; python_version < '3.10' +packaging diff --git a/sopel/__init__.py b/sopel/__init__.py index b5ad956c3b..82b8f0a0c4 100644 --- a/sopel/__init__.py +++ b/sopel/__init__.py @@ -16,7 +16,11 @@ import re import sys -import pkg_resources +try: + import importlib.metadata as importlib_metadata +except ImportError: + # TODO: remove fallback when dropping py3.7 + import importlib_metadata __all__ = [ 'bot', @@ -41,7 +45,7 @@ 'something like "en_US.UTF-8".', file=sys.stderr) -__version__ = pkg_resources.get_distribution('sopel').version +__version__ = importlib_metadata.version('sopel') def _version_info(version=__version__): diff --git a/sopel/lifecycle.py b/sopel/lifecycle.py index a0cdb8e0e4..cbc51ce094 100644 --- a/sopel/lifecycle.py +++ b/sopel/lifecycle.py @@ -15,7 +15,7 @@ import traceback from typing import Callable, Optional -from pkg_resources import parse_version +from packaging.version import parse as parse_version from sopel import __version__ diff --git a/sopel/plugins/__init__.py b/sopel/plugins/__init__.py index 86e1db00c1..6a59149a6a 100644 --- a/sopel/plugins/__init__.py +++ b/sopel/plugins/__init__.py @@ -12,7 +12,7 @@ * extra directories defined in the settings * homedir's ``plugins`` directory -* ``sopel.plugins`` setuptools entry points +* ``sopel.plugins`` entry point group * ``sopel_modules``'s subpackages * ``sopel.modules``'s core plugins @@ -33,7 +33,12 @@ import itertools import os -import pkg_resources +try: + import importlib_metadata +except ImportError: + # TODO: use stdlib only when possible, after dropping py3.9 + # stdlib does not support `entry_points(group='filter')` until py3.10 + import importlib.metadata as importlib_metadata from . import exceptions, handlers, rules # noqa @@ -93,17 +98,17 @@ def find_sopel_modules_plugins(): def find_entry_point_plugins(group='sopel.plugins'): - """List plugins from a setuptools entry point group. + """List plugins from an entry point group. - :param str group: setuptools entry point group to look for - (defaults to ``sopel.plugins``) + :param str group: entry point group to search in (defaults to + ``sopel.plugins``) :return: yield instances of :class:`~.handlers.EntryPointPlugin` - created from setuptools entry point given ``group`` + created from each entry point in the ``group`` - This function finds plugins declared under a setuptools entry point; by - default it uses the ``sopel.plugins`` entry point. + This function finds plugins declared under an entry point group; by + default it looks in the ``sopel.plugins`` group. """ - for entry_point in pkg_resources.iter_entry_points(group): + for entry_point in importlib_metadata.entry_points(group=group): yield handlers.EntryPointPlugin(entry_point) @@ -139,7 +144,7 @@ def enumerate_plugins(settings): * :func:`find_internal_plugins` for internal plugins * :func:`find_sopel_modules_plugins` for ``sopel_modules.*`` plugins - * :func:`find_entry_point_plugins` for plugins exposed by setuptools + * :func:`find_entry_point_plugins` for plugins exposed via packages' entry points * :func:`find_directory_plugins` for plugins in ``$homedir/plugins``, and in extra directories as defined by ``settings.core.extra`` @@ -201,7 +206,7 @@ def get_usable_plugins(settings): * extra directories defined in the settings * homedir's ``plugins`` directory - * ``sopel.plugins`` setuptools entry points + * ``sopel.plugins`` entry point group * ``sopel_modules``'s subpackages * ``sopel.modules``'s core plugins diff --git a/sopel/plugins/handlers.py b/sopel/plugins/handlers.py index f2195d315d..c16d7852fb 100644 --- a/sopel/plugins/handlers.py +++ b/sopel/plugins/handlers.py @@ -18,13 +18,13 @@ At the moment, three types of plugin are handled: -* :class:`PyModulePlugin`: manage plugins that can be imported as Python +* :class:`PyModulePlugin`: manages plugins that can be imported as Python module from a Python package, i.e. where ``from package import name`` works -* :class:`PyFilePlugin`: manage plugins that are Python files on the filesystem +* :class:`PyFilePlugin`: manages plugins that are Python files on the filesystem or Python directory (with an ``__init__.py`` file inside), that cannot be directly imported and extra steps are necessary -* :class:`EntryPointPlugin`: manage plugins that are declared by a setuptools - entry point; other than that, it behaves like a :class:`PyModulePlugin` +* :class:`EntryPointPlugin`: manages plugins that are declared by an entry + point; it otherwise behaves like a :class:`PyModulePlugin` All expose the same interface and thereby abstract the internal implementation away from the rest of the application. @@ -512,17 +512,17 @@ def reload(self): class EntryPointPlugin(PyModulePlugin): - """Sopel plugin loaded from a ``setuptools`` entry point. + """Sopel plugin loaded from an entry point. - :param entry_point: a ``setuptools`` entry point object + :param entry_point: an entry point object - This handler loads a Sopel plugin exposed by a ``setuptools`` entry point. - It expects to be able to load a module object from the entry point, and to + This handler loads a Sopel plugin exposed by a package's entry point. It + expects to be able to load a module object from the entry point, and to work as a :class:`~.PyModulePlugin` from that module. - By default, Sopel uses the entry point ``sopel.plugins``. To use that for - their plugin, developers must define an entry point either in their - ``setup.py`` file or their ``setup.cfg`` file:: + By default, Sopel searches within the entry point group ``sopel.plugins``. + To use that for their own plugins, developers must define an entry point + either in their ``setup.py`` file or their ``setup.cfg`` file:: # in setup.py file setup( @@ -537,11 +537,11 @@ class EntryPointPlugin(PyModulePlugin): And this plugin can be loaded with:: - >>> from pkg_resources import iter_entry_points + >>> from importlib_metadata import entry_points >>> from sopel.plugins.handlers import EntryPointPlugin >>> plugin = [ ... EntryPointPlugin(ep) - ... for ep in iter_entry_points('sopel.plugins', 'custom') + ... for ep in entry_points(group='sopel.plugins', name='custom') ... ][0] >>> plugin.load() >>> plugin.name @@ -556,10 +556,13 @@ class EntryPointPlugin(PyModulePlugin): Sopel uses the :func:`~sopel.plugins.find_entry_point_plugins` function internally to search entry points. - Entry point is a `standard feature of setuptools`__ for Python, used - by other applications (like ``pytest``) for their plugins. + Entry points are a `standard packaging mechanism`__ for Python, used by + other applications (such as ``pytest``) for their plugins. - .. __: https://setuptools.readthedocs.io/en/stable/setuptools.html#dynamic-discovery-of-services-and-plugins + The ``importlib_metadata`` backport package is used on Python versions + older than 3.10, but its API is the same as :mod:`importlib.metadata`. + + .. __: https://packaging.python.org/en/latest/specifications/entry-points/ """ @@ -583,9 +586,8 @@ def get_meta_description(self): :return: meta description information :rtype: :class:`dict` - This returns the same keys as - :meth:`PyModulePlugin.get_meta_description`; the ``source`` key is - modified to contain the setuptools entry point:: + This returns the output of :meth:`PyModulePlugin.get_meta_description` + but with the ``source`` key modified to reference the entry point:: { 'name': 'example', @@ -598,6 +600,6 @@ def get_meta_description(self): """ data = super().get_meta_description() data.update({ - 'source': str(self.entry_point), + 'source': self.entry_point.name + ' = ' + self.entry_point.value, }) return data diff --git a/test/plugins/test_plugins_handlers.py b/test/plugins/test_plugins_handlers.py index 188a96e0dd..1bc26fcd3c 100644 --- a/test/plugins/test_plugins_handlers.py +++ b/test/plugins/test_plugins_handlers.py @@ -4,9 +4,14 @@ import os import sys -import pkg_resources import pytest +try: + import importlib.metadata as importlib_metadata +except ImportError: + # TODO: remove fallback when dropping py3.9 + import importlib_metadata + from sopel.plugins import handlers @@ -62,15 +67,14 @@ def test_get_label_pyfile_loaded(plugin_tmpfile): def test_get_label_entrypoint(plugin_tmpfile): - # generate setuptools Distribution object + # set up for manual load/import distrib_dir = os.path.dirname(plugin_tmpfile.strpath) - distrib = pkg_resources.Distribution(distrib_dir) sys.path.append(distrib_dir) # load the entry point try: - entry_point = pkg_resources.EntryPoint( - 'test_plugin', 'file_mod', dist=distrib) + entry_point = importlib_metadata.EntryPoint( + 'test_plugin', 'file_mod', 'sopel.plugins') plugin = handlers.EntryPointPlugin(entry_point) plugin.load() finally: diff --git a/test/test_plugins.py b/test/test_plugins.py index 7110c1dc07..ced8914988 100644 --- a/test/test_plugins.py +++ b/test/test_plugins.py @@ -3,9 +3,14 @@ import sys -import pkg_resources import pytest +try: + import importlib.metadata as importlib_metadata +except ImportError: + # TODO: remove fallback when dropping py3.9 + import importlib_metadata + from sopel import plugins @@ -132,14 +137,13 @@ def test_plugin_load_entry_point(tmpdir): mod_file = root.join('file_mod.py') mod_file.write(MOCK_MODULE_CONTENT) - # generate setuptools Distribution object - distrib = pkg_resources.Distribution(root.strpath) + # set up for manual load/import sys.path.append(root.strpath) # load the entry point try: - entry_point = pkg_resources.EntryPoint( - 'test_plugin', 'file_mod', dist=distrib) + entry_point = importlib_metadata.EntryPoint( + 'test_plugin', 'file_mod', 'sopel.plugins') plugin = plugins.handlers.EntryPointPlugin(entry_point) plugin.load() finally: