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

build, meta: drop Python 3.7 support, checks, and CI job #2500

Merged
merged 11 commits into from
Aug 9, 2023
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
dgw marked this conversation as resolved.
Show resolved Hide resolved

"""
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))):
dgw marked this conversation as resolved.
Show resolved Hide resolved
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):
dgw marked this conversation as resolved.
Show resolved Hide resolved
"""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',
dgw marked this conversation as resolved.
Show resolved Hide resolved
'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__)
dgw marked this conversation as resolved.
Show resolved Hide resolved
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