From 45ce17c8a99af8669511fa067044a06b3615985c Mon Sep 17 00:00:00 2001 From: dgw Date: Mon, 12 Jun 2023 15:48:48 -0500 Subject: [PATCH 01/11] build, meta: drop Python 3.7 support, checks, and CI job --- .github/workflows/ci.yml | 1 - README.rst | 2 +- pyproject.toml | 3 +-- setup.cfg | 2 +- sopel/cli/run.py | 4 ++-- 5 files changed, 5 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a530ca9db..4add455028 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,6 @@ jobs: strategy: matrix: python-version: - - "3.7" - "3.8" - "3.9" - "3.10" diff --git a/README.rst b/README.rst index 1235dd867c..071e5f8c6d 100644 --- a/README.rst +++ b/README.rst @@ -31,7 +31,7 @@ First, either clone the repository with ``git clone https://github.com/sopel-irc/sopel.git`` or download a tarball `from GitHub `_. -Note: Sopel requires Python 3.7+ to run. +Note: Sopel requires Python 3.8+ to run. In the source directory (whether cloned or from the tarball) run ``pip install -e .``. You can then run ``sopel`` to configure and start the bot. diff --git a/pyproject.toml b/pyproject.toml index 870685dcb3..f0781e80e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,14 +35,13 @@ classifiers = [ "License :: Eiffel Forum License (EFL)", "License :: OSI Approved :: Eiffel Forum License", "Operating System :: POSIX :: Linux", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Topic :: Communications :: Chat :: Internet Relay Chat", ] -requires-python = ">=3.7" +requires-python = ">=3.8" dependencies = [ "xmltodict>=0.12,<0.14", "pytz", diff --git a/setup.cfg b/setup.cfg index 088f07157e..9d5eb9c203 100644 --- a/setup.cfg +++ b/setup.cfg @@ -16,7 +16,7 @@ ignore = # Sopel no longer supports Python versions that require them. FI58, # These would require future imports that are not needed any more on Sopel's - # oldest supported Python version (3.7). + # oldest supported Python version (3.8). FI10,FI11,FI12,FI13,FI14,FI15,FI16,FI17, # We use postponed annotation evaluation TC2, diff --git a/sopel/cli/run.py b/sopel/cli/run.py index ff3718d01a..003f25d9dd 100755 --- a/sopel/cli/run.py +++ b/sopel/cli/run.py @@ -22,8 +22,8 @@ # This is in case someone somehow manages to install Sopel on an old version # of pip (<9.0.0), which doesn't know about `python_requires`, or tries to run # from source on an unsupported version of Python. -if sys.version_info < (3, 7): - utils.stderr('Error: Sopel requires Python 3.7+.') +if sys.version_info < (3, 8): + utils.stderr('Error: Sopel requires Python 3.8+.') sys.exit(1) LOGGER = logging.getLogger(__name__) From 4d2f19c87edf52dbf0f1a5e6ca8573ef7ef6545e Mon Sep 17 00:00:00 2001 From: dgw Date: Mon, 12 Jun 2023 15:50:57 -0500 Subject: [PATCH 02/11] docs/docstrings: replace links to Python 3.7 docs w/links to 3.11 pages Linking to just `/3/` is admittedly risky, because item/anchor locations can change across Python versions. Bumping the links from 3.7 to 3.11 gives us a few more years of not needing to touch them. --- sopel/bot.py | 4 ++-- sopel/plugins/rules.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/sopel/bot.py b/sopel/bot.py index cd9b4f0919..28847c0ba1 100644 --- a/sopel/bot.py +++ b/sopel/bot.py @@ -1255,8 +1255,8 @@ def search_url_callbacks(self, url): The Python documentation for the `re.search`__ function and the `match object`__. - .. __: https://docs.python.org/3.7/library/re.html#re.search - .. __: https://docs.python.org/3.7/library/re.html#match-objects + .. __: https://docs.python.org/3.11/library/re.html#re.search + .. __: https://docs.python.org/3.11/library/re.html#match-objects """ for regex, function in self._url_callbacks.items(): diff --git a/sopel/plugins/rules.py b/sopel/plugins/rules.py index de99a94367..9f0937d2d1 100644 --- a/sopel/plugins/rules.py +++ b/sopel/plugins/rules.py @@ -671,7 +671,7 @@ def match(self, bot, pretrigger) -> Iterable: This method must return a list of `match objects`__. - .. __: https://docs.python.org/3.7/library/re.html#match-objects + .. __: https://docs.python.org/3.11/library/re.html#match-objects """ @abc.abstractmethod @@ -842,7 +842,7 @@ def parse(self, text) -> Generator: :return: yield a list of match object :rtype: generator of `re.match`__ - .. __: https://docs.python.org/3.7/library/re.html#match-objects + .. __: https://docs.python.org/3.11/library/re.html#match-objects """ @abc.abstractmethod From a903e5d3f265c742788afa65a3e3001f5e61dcd9 Mon Sep 17 00:00:00 2001 From: dgw Date: Mon, 12 Jun 2023 15:54:00 -0500 Subject: [PATCH 03/11] sopel: `importlib_metadata` backport -> stdlib `importlib.metadata` Can't drop the `importlib_metadata` requirement yet, though. We use some stuff elsewhere that isn't stable in stdlib until py3.10. --- sopel/__init__.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/sopel/__init__.py b/sopel/__init__.py index 3c540d416c..2b360528e2 100644 --- a/sopel/__init__.py +++ b/sopel/__init__.py @@ -13,13 +13,11 @@ from __future__ import annotations from collections import namedtuple +import importlib.metadata import locale import re import sys -# TODO: replace with stdlib importlib.metadata when dropping py3.7 -# version info used in this module works from py3.8+ -import importlib_metadata __all__ = [ 'bot', @@ -43,7 +41,7 @@ 'something like "en_US.UTF-8".', file=sys.stderr) -__version__ = importlib_metadata.version('sopel') +__version__ = importlib.metadata.version('sopel') def _version_info(version=__version__): From de572cd075357e3c4a36938b0c1dfcde77498d76 Mon Sep 17 00:00:00 2001 From: dgw Date: Wed, 2 Aug 2023 00:29:32 -0700 Subject: [PATCH 04/11] plugins.handlers: use `TypedDict` for `get_meta_description()` returns Played with using intentionally wrong types in the definition of a `PluginMetaDescription` and running `mypy` against it, but didn't see any (new) errors flagged over that. Maybe I did it wrong, but it took enough work shuffling docstrings around that I'm committing this anyway. --- sopel/plugins/handlers.py | 85 ++++++++++++++++++++++----------------- 1 file changed, 47 insertions(+), 38 deletions(-) diff --git a/sopel/plugins/handlers.py b/sopel/plugins/handlers.py index 3fa8a18c6b..8828bd1631 100644 --- a/sopel/plugins/handlers.py +++ b/sopel/plugins/handlers.py @@ -50,7 +50,7 @@ import itertools import os import sys -from typing import Optional, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING, TypedDict # TODO: refactor along with usage in sopel.__init__ in py3.8+ world import importlib_metadata @@ -64,6 +64,26 @@ from types import ModuleType +class PluginMetaDescription(TypedDict): + """Meta description of a plugin, as a dictionary. + + This dictionary is expected to contain specific keys: + + * name: a short name for the plugin + * label: a descriptive label for the plugin; see + :meth:`~sopel.plugins.handlers.AbstractPluginHandler.get_label` + * type: the plugin's type + * source: the plugin's source + (filesystem path, python module/import path, etc.) + * version: the plugin's version string if available, otherwise ``None`` + """ + name: str + label: str + type: str + source: str + version: Optional[str] + + class AbstractPluginHandler(abc.ABC): """Base class for plugin handlers. @@ -107,22 +127,14 @@ def get_label(self) -> str: """ @abc.abstractmethod - def get_meta_description(self) -> dict: + def get_meta_description(self) -> PluginMetaDescription: """Retrieve a meta description for the plugin. - :return: meta description information + :return: Metadata about the plugin :rtype: :class:`dict` - The expected keys are: - - * name: a short name for the plugin - * label: a descriptive label for the plugin - * type: the plugin's type - * source: the plugin's source - (filesystem path, python import path, etc.) - * version: the plugin's version string if available, otherwise ``None`` + The expected keys are detailed in :class:`PluginMetaDescription`. """ - # TODO: change return type to a TypedDict when dropping py3.7 @abc.abstractmethod def get_version(self): @@ -284,26 +296,21 @@ def get_label(self): lines = inspect.cleandoc(module_doc).splitlines() return default_label if not lines else lines[0] - def get_meta_description(self): + def get_meta_description(self) -> PluginMetaDescription: """Retrieve a meta description for the plugin. - :return: meta description information + :return: Metadata about the plugin :rtype: :class:`dict` - The keys are: - - * name: the plugin's name - * label: see :meth:`~sopel.plugins.handlers.PyModulePlugin.get_label` - * type: see :attr:`PLUGIN_TYPE` - * source: the name of the plugin's module - * version: the version string of the plugin if available, otherwise ``None`` + The expected keys are detailed in :class:`PluginMetaDescription`. - Example:: + This implementation uses its module's dotted import path as the + ``source`` value:: { 'name': 'example', - 'type: 'python-module', - 'label: 'example plugin', + 'type': 'python-module', + 'label': 'example plugin', 'source': 'sopel_modules.example', 'version': '3.1.2', } @@ -489,21 +496,21 @@ def _load(self): self.module_spec.loader.exec_module(module) return module - def get_meta_description(self): + def get_meta_description(self) -> PluginMetaDescription: """Retrieve a meta description for the plugin. - :return: meta description information + :return: Metadata about the plugin :rtype: :class:`dict` - This returns the same keys as - :meth:`PyModulePlugin.get_meta_description`; the ``source`` key is - modified to contain the source file's path instead of its Python module - dotted path:: + The expected keys are detailed in :class:`PluginMetaDescription`. + + This implementation uses its source file's path as the ``source`` + value:: { 'name': 'example', - 'type: 'python-file', - 'label: 'example plugin', + 'type': 'python-file', + 'label': 'example plugin', 'source': '/home/username/.sopel/plugins/example.py', 'version': '3.1.2', } @@ -616,19 +623,21 @@ def get_version(self) -> Optional[str]: return version - def get_meta_description(self): + def get_meta_description(self) -> PluginMetaDescription: """Retrieve a meta description for the plugin. - :return: meta description information + :return: Metadata about the plugin :rtype: :class:`dict` - This returns the output of :meth:`PyModulePlugin.get_meta_description` - but with the ``source`` key modified to reference the entry point:: + The expected keys are detailed in :class:`PluginMetaDescription`. + + This implementation uses its entry point definition as the ``source`` + value:: { 'name': 'example', - 'type: 'setup-entrypoint', - 'label: 'example plugin', + 'type': 'setup-entrypoint', + 'label': 'example plugin', 'source': 'example = my_plugin.example', 'version': '3.1.2', } From 451a36e030f2feb5da50d78b0c1ff3878252b940 Mon Sep 17 00:00:00 2001 From: dgw Date: Mon, 12 Jun 2023 16:26:23 -0500 Subject: [PATCH 05/11] announce: simplify chunking loop to use walrus operator The test covering `_chunks()` still passes, so hooray! --- sopel/modules/announce.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/sopel/modules/announce.py b/sopel/modules/announce.py index 23da759091..6920d069e6 100644 --- a/sopel/modules/announce.py +++ b/sopel/modules/announce.py @@ -25,12 +25,8 @@ def _chunks(items, size): # This approach is safer than slicing with non-subscriptable types, # for example `dict_keys` objects iterator = iter(items) - # TODO: Simplify to assignment expression (`while cond := expr`) - # when dropping Python 3.7 - chunk = tuple(itertools.islice(iterator, size)) - while chunk: + while (chunk := tuple(itertools.islice(iterator, size))): yield chunk - chunk = tuple(itertools.islice(iterator, size)) @plugin.command('announce') From 8ad45b733dd395a07c59a55047070a51b352a28a Mon Sep 17 00:00:00 2001 From: dgw Date: Mon, 12 Jun 2023 16:28:53 -0500 Subject: [PATCH 06/11] irc.isupport: simplify `OrderedDict` -> `dict` Regular `dict` is promised to maintain insertion order as of Python 3.7 (which means this could have been done sooner, but it got forgotten, so we're doing it at the point of dropping Python <3.8 instead). --- sopel/irc/isupport.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/sopel/irc/isupport.py b/sopel/irc/isupport.py index 995f564fa1..2af970f23d 100644 --- a/sopel/irc/isupport.py +++ b/sopel/irc/isupport.py @@ -13,7 +13,6 @@ # Licensed under the Eiffel Forum License 2. from __future__ import annotations -from collections import OrderedDict import functools import itertools import re @@ -392,10 +391,7 @@ def PREFIX(self) -> dict[str, str]: if 'PREFIX' not in self: raise AttributeError('PREFIX') - # This can use a normal dict once we drop python 3.6, as 3.7 promises - # `dict` maintains insertion order. Since `OrderedDict` subclasses - # `dict`, we'll not promise to always return the former. - return OrderedDict(self['PREFIX']) + return dict(self['PREFIX']) @property def TARGMAX(self): From 2bcddffaa7e90bafea558b8fe127b563edaf2960 Mon Sep 17 00:00:00 2001 From: dgw Date: Wed, 2 Aug 2023 00:32:11 -0700 Subject: [PATCH 07/11] plugins.rules: clean up forgotten py2 list-copy holdover Not doing exactly as the comment says because different Rule subclasses pass aliases as either tuple or list depending on how whoever wrote the subclass was feeling that day. Instead, let's simplify the code to do everything in one step, and unpack the `aliases` iterable so its type won't even matter. Co-authored-by: SnoopJ --- sopel/plugins/rules.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/sopel/plugins/rules.py b/sopel/plugins/rules.py index 9f0937d2d1..a38da75dd8 100644 --- a/sopel/plugins/rules.py +++ b/sopel/plugins/rules.py @@ -87,9 +87,7 @@ def _clean_rules(rules, nick, aliases): def _compile_pattern(pattern, nick, aliases=None): if aliases: - nicks = list(aliases) # alias_nicks.copy() doesn't work in py2 - nicks.append(nick) - nick = '(?:%s)' % '|'.join(re.escape(n) for n in nicks) + nick = '(?:%s)' % '|'.join(re.escape(n) for n in (nick, *aliases)) else: nick = re.escape(nick) From 314c8a2d637f48a301658bf5d515f32b6c859f4a Mon Sep 17 00:00:00 2001 From: dgw Date: Wed, 2 Aug 2023 00:50:24 -0700 Subject: [PATCH 08/11] plugins.rules: fix newly detected case of F811 in pyflakes 3.1.0+ --- sopel/plugins/rules.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/sopel/plugins/rules.py b/sopel/plugins/rules.py index a38da75dd8..a40b41ae66 100644 --- a/sopel/plugins/rules.py +++ b/sopel/plugins/rules.py @@ -1760,7 +1760,6 @@ class URLCallback(Rule): @classmethod def from_callable(cls, settings, handler): - execute_handler = handler regexes = cls.regex_from_callable(settings, handler) kwargs = cls.kwargs_from_callable(handler) @@ -1772,13 +1771,19 @@ def from_callable(cls, settings, handler): # account for the 'self' parameter when the handler is a method match_count = 4 + execute_handler = handler argspec = inspect.getfullargspec(handler) if len(argspec.args) >= match_count: @functools.wraps(handler) - def execute_handler(bot, trigger): + def handler_match_wrapper(bot, trigger): return handler(bot, trigger, match=trigger) + # don't directly `def execute_handler` to override it; + # doing incurs the wrath of pyflakes in the form of + # "F811: Redefinition of unused name" + execute_handler = handler_match_wrapper + kwargs.update({ 'handler': execute_handler, 'schemes': settings.core.auto_url_schemes, From 57b692c7475d34579169138ded518371aff4295a Mon Sep 17 00:00:00 2001 From: dgw Date: Wed, 2 Aug 2023 00:32:50 -0700 Subject: [PATCH 09/11] plugins.handlers: use stdlib for `EntryPointPlugin.get_version()` `importlib.metadata` officially starts existing in Python 3.8, and we are removing support for Python 3.7 before Sopel 8's stable release. That means we can use the stdlib approach here to get the version string for an entry-point plugin, and drop another `import importlib_metadata`. I can't wait for Python 3.9's EOL... We can remove `importlib_metadata` completely then. --- sopel/plugins/handlers.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/sopel/plugins/handlers.py b/sopel/plugins/handlers.py index 8828bd1631..5cca349f9e 100644 --- a/sopel/plugins/handlers.py +++ b/sopel/plugins/handlers.py @@ -52,9 +52,6 @@ import sys from typing import Optional, TYPE_CHECKING, TypedDict -# TODO: refactor along with usage in sopel.__init__ in py3.8+ world -import importlib_metadata - from sopel import __version__ as release, loader, plugin as plugin_decorators from . import exceptions @@ -614,9 +611,13 @@ def get_version(self) -> Optional[str]: """ version: Optional[str] = super().get_version() - if version is None and hasattr(self.module, "__package__"): + if ( + version is None + and hasattr(self.module, "__package__") + and self.module.__package__ is not None + ): try: - version = importlib_metadata.version(self.module.__package__) + version = importlib.metadata.version(self.module.__package__) except ValueError: # package name is probably empty-string; just give up pass From c743861400e8a5d680104c61218f93d16790b11e Mon Sep 17 00:00:00 2001 From: dgw Date: Thu, 3 Aug 2023 10:08:37 -0700 Subject: [PATCH 10/11] tld: be more specific about JSONDecodeError instead of ValueError As of Python 3.5, we can do this. It's overdue. --- sopel/modules/tld.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sopel/modules/tld.py b/sopel/modules/tld.py index 3ef96072fa..33a23cd887 100644 --- a/sopel/modules/tld.py +++ b/sopel/modules/tld.py @@ -11,6 +11,7 @@ from datetime import datetime from encodings import idna from html.parser import HTMLParser +from json import JSONDecodeError import logging import re from typing import Union @@ -271,8 +272,7 @@ def _update_tld_data(bot, which, force=False): params=parameters, ).json() data_pages.append(tld_response["parse"]["text"]) - # py <3.5 needs ValueError instead of more specific json.decoder.JSONDecodeError - except (requests.exceptions.RequestException, ValueError, KeyError): + except (requests.exceptions.RequestException, JSONDecodeError, KeyError): # Log error and continue life; it'll be fine LOGGER.warning( 'Error fetching TLD data from "%s" on Wikipedia; will try again later.', From 2a6d14678d4ea94f54dc8d19d409bf01e7dad873 Mon Sep 17 00:00:00 2001 From: dgw Date: Thu, 3 Aug 2023 11:27:19 -0700 Subject: [PATCH 11/11] test: update `plugins` tests to use importlib.metadata directly importlib.metadata.EntryPoint *shouldn't* differ from Python 3.8 onward, unless there's something missing from the documentation. If this breaks stuff on older Python releases (I'm on 3.10 locally), we will defer this change until we drop 3.9 as planned per the code comments. --- test/plugins/test_plugins_handlers.py | 5 ++--- test/test_plugins.py | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/test/plugins/test_plugins_handlers.py b/test/plugins/test_plugins_handlers.py index ef77596e3b..f340353695 100644 --- a/test/plugins/test_plugins_handlers.py +++ b/test/plugins/test_plugins_handlers.py @@ -1,11 +1,10 @@ """Tests for the ``sopel.plugins.handlers`` module.""" from __future__ import annotations +import importlib.metadata import os import sys -# TODO: use stdlib importlib.metadata when dropping py3.9 -import importlib_metadata import pytest from sopel.plugins import handlers @@ -89,7 +88,7 @@ def test_get_label_entrypoint(plugin_tmpfile): # load the entry point try: - entry_point = importlib_metadata.EntryPoint( + entry_point = importlib.metadata.EntryPoint( 'test_plugin', 'file_mod', 'sopel.plugins') plugin = handlers.EntryPointPlugin(entry_point) plugin.load() diff --git a/test/test_plugins.py b/test/test_plugins.py index 21b77c7d41..21c6d6860c 100644 --- a/test/test_plugins.py +++ b/test/test_plugins.py @@ -1,10 +1,9 @@ """Test for the ``sopel.plugins`` module.""" from __future__ import annotations +import importlib.metadata import sys -# TODO: switch to stdlib importlib.metdata when dropping py3.9 -import importlib_metadata import pytest from sopel import plugins @@ -138,7 +137,7 @@ def test_plugin_load_entry_point(tmpdir): # load the entry point try: - entry_point = importlib_metadata.EntryPoint( + entry_point = importlib.metadata.EntryPoint( 'test_plugin', 'file_mod', 'sopel.plugins') plugin = plugins.handlers.EntryPointPlugin(entry_point) plugin.load()