Skip to content

Commit

Permalink
Merge pull request #2500 from sopel-irc/rm-py3.7
Browse files Browse the repository at this point in the history
build, meta: drop Python 3.7 support, checks, and CI job
  • Loading branch information
dgw authored Aug 9, 2023
2 parents 271b306 + 2a6d146 commit d58d6a7
Show file tree
Hide file tree
Showing 14 changed files with 80 additions and 81 deletions.
1 change: 0 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ jobs:
strategy:
matrix:
python-version:
- "3.7"
- "3.8"
- "3.9"
- "3.10"
Expand Down
2 changes: 1 addition & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
<https://github.com/sopel-irc/sopel/releases/latest>`_.

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.
Expand Down
3 changes: 1 addition & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 2 additions & 4 deletions sopel/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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__):
Expand Down
4 changes: 2 additions & 2 deletions sopel/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
4 changes: 2 additions & 2 deletions sopel/cli/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down
6 changes: 1 addition & 5 deletions sopel/irc/isupport.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
6 changes: 1 addition & 5 deletions sopel/modules/announce.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
4 changes: 2 additions & 2 deletions sopel/modules/tld.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.',
Expand Down
96 changes: 53 additions & 43 deletions sopel/plugins/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,7 @@
import itertools
import os
import sys
from typing import Optional, TYPE_CHECKING

# TODO: refactor along with usage in sopel.__init__ in py3.8+ world
import importlib_metadata
from typing import Optional, TYPE_CHECKING, TypedDict

from sopel import __version__ as release, loader, plugin as plugin_decorators
from . import exceptions
Expand All @@ -64,6 +61,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.
Expand Down Expand Up @@ -107,22 +124,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):
Expand Down Expand Up @@ -284,26 +293,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',
}
Expand Down Expand Up @@ -489,21 +493,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',
}
Expand Down Expand Up @@ -607,28 +611,34 @@ 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

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',
}
Expand Down
17 changes: 10 additions & 7 deletions sopel/plugins/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -671,7 +669,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
Expand Down Expand Up @@ -842,7 +840,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
Expand Down Expand Up @@ -1762,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)

Expand All @@ -1774,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,
Expand Down
5 changes: 2 additions & 3 deletions test/plugins/test_plugins_handlers.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()
Expand Down
Loading

0 comments on commit d58d6a7

Please sign in to comment.