diff --git a/dev-requirements.txt b/dev-requirements.txt index 7831466753..f37c2b9ab6 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -6,11 +6,12 @@ flake8-future-import flake8-import-order flake8-type-checking; python_version >= '3.8' # Sphinx theme -furo==2022.4.7 +furo==2023.9.10 pytest~=7.1.0 pytest-vcr~=1.0.2 requests-mock~=1.9.3 -sphinx>=4,<5 +sphinx>=7.1.0,<8; python_version <= '3.8' +sphinx>=7.2.0,<8; python_version > '3.8' # specify exact autoprogram version because the new (in 2021) maintainer # showed that they will indeed make major changes in patch versions sphinxcontrib-autoprogram==0.1.8 diff --git a/docs/source/conf.py b/docs/source/conf.py index e8b31c7689..fc70cb3c8d 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -11,9 +11,7 @@ # serve to show the default. from datetime import date -import sys, os -parentdir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -os.sys.path.insert(0,parentdir) + from sopel import __version__ # If extensions (or modules to document with autodoc) are in another directory, @@ -24,7 +22,7 @@ # -- General configuration ----------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. -needs_sphinx = '4.0' +needs_sphinx = '7.1' # todo: upgrade when Py3.8 reaches EOL # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. @@ -111,7 +109,22 @@ pygments_dark_style = 'monokai' # A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] +modindex_common_prefix = ['sopel.'] + +# If a signature’s length in characters exceeds the number set, each parameter +# within the signature will be displayed on an individual logical line. +maximum_signature_line_length = 80 + + +# -- Options for autodoc ------------------------------------------------------- + +autodoc_type_aliases = { + 'Casemapping': 'sopel.tools.identifiers.Casemapping', + 'IdentifierFactory': 'sopel.tools.identifiers.IdentifierFactory', + 'ModeTuple': 'sopel.irc.modes.ModeTuple', + 'ModeDetails': 'sopel.irc.modes.ModeDetails', + 'PrivilegeDetails': 'sopel.irc.modes.PrivilegeDetails', +} # -- Options for HTML output --------------------------------------------------- diff --git a/docs/source/index.rst b/docs/source/index.rst index 2726ad3a4d..08952a9805 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -25,6 +25,8 @@ Documentation plugin package tests + genindex + modindex .. toctree:: :caption: Donate diff --git a/docs/source/package/plugins/rules.rst b/docs/source/package/plugins/rules.rst index a3ffea267c..196d4eef94 100644 --- a/docs/source/package/plugins/rules.rst +++ b/docs/source/package/plugins/rules.rst @@ -14,16 +14,10 @@ sopel.plugins.rules :members: :undoc-members: - .. class:: TypedRule - - A :class:`~typing.TypeVar` bound to :class:`AbstractRule`. When used in - the :meth:`AbstractRule.from_callable` class method, it means the return - value must be an instance of the class used to call that method and not a - different subclass of ``AbstractRule``. - - .. versionadded:: 8.0 - - This ``TypeVar`` was added as part of a goal to start type-checking - Sopel and is not used at runtime. + .. autoclass:: TypedRule + :members: + :undoc-members: - .. TODO remove when sphinx-autodoc can manage TypeVar properly. + .. autoclass:: RuleMetrics + :members: + :undoc-members: diff --git a/docs/source/plugin/advanced.rst b/docs/source/plugin/advanced.rst index f7f5b7f9b8..d9a50cbc13 100644 --- a/docs/source/plugin/advanced.rst +++ b/docs/source/plugin/advanced.rst @@ -206,6 +206,10 @@ handler to run after the capability is acknowledged or denied by the server:: .. autoclass:: sopel.plugin.CapabilityNegotiation :members: +.. autoclass:: sopel.plugin.CapabilityHandler + :members: + :special-members: __call__ + Working with capabilities ------------------------- diff --git a/sopel/db.py b/sopel/db.py index 8c39ce918d..8deaa8035c 100644 --- a/sopel/db.py +++ b/sopel/db.py @@ -29,14 +29,13 @@ from sqlalchemy.sql import delete, func, select, update from sopel.lifecycle import deprecated -from sopel.tools.identifiers import Identifier +from sopel.tools.identifiers import Identifier, IdentifierFactory if typing.TYPE_CHECKING: from collections.abc import Iterable LOGGER = logging.getLogger(__name__) -IdentifierFactory = typing.Callable[[str], Identifier] def _deserialize(value): @@ -146,7 +145,7 @@ def __init__( config, identifier_factory: IdentifierFactory = Identifier, ) -> None: - self.make_identifier = identifier_factory + self.make_identifier: IdentifierFactory = identifier_factory if config.core.db_url is not None: self.url = make_url(config.core.db_url) diff --git a/sopel/plugin.py b/sopel/plugin.py index 3554e74ace..37f06fa572 100644 --- a/sopel/plugin.py +++ b/sopel/plugin.py @@ -18,7 +18,7 @@ Callable, Optional, Pattern, - Tuple, + Protocol, TYPE_CHECKING, Union, ) @@ -121,30 +121,15 @@ class CapabilityNegotiation(enum.Enum): """ -if TYPE_CHECKING: - CapabilityHandler = Callable[ - [Tuple[str, ...], SopelWrapper, bool], - CapabilityNegotiation, - ] - - -class capability: - """Decorate a function to request a capability and handle the result. +class CapabilityHandler(Protocol): + """:class:`~typing.Protocol` definition for capability handler. - :param name: name of the capability to negotiate with the server; this - positional argument can be used multiple times to form a - single ``CAP REQ`` - :param handler: optional keyword argument, acknowledgement handler + When a plugin requests a capability, it can define a callback handler for + that request using :class:`capability` as a decorator. That handler will be + called upon Sopel receiving either an ``ACK`` (capability enabled) or a + ``NAK`` (capability denied) CAP message. - The Client Capability Negotiation is a feature of IRCv3 that exposes a - mechanism for a server to advertise a list of features and for clients to - request them when they are available. - - This decorator will register a capability request, allowing the bot to - request capabilities if they are available. You can request more than one - at a time, which will make for one single request. - - The handler must follow this interface:: + Example:: from sopel import plugin from sopel.bot import SopelWrapper @@ -168,7 +153,48 @@ def capability_handler( # always return if Sopel can send "CAP END" (DONE) # or if the plugin must notify the bot for that later (CONTINUE) - return CapabilityNegotiation.DONE + return plugin.CapabilityNegotiation.DONE + + .. note:: + + This protocol class should be used for type checking and documentation + purposes only. + + """ + def __call__( + self, + cap_req: tuple[str, ...], + bot: SopelWrapper, + acknowledged: bool, + ) -> CapabilityNegotiation: + """A capability handler must be a callable with this signature. + + :param cap_req: the capability request, as a tuple of string + :param bot: the bot instance + :param acknowledged: that flag that tells if the capability is enabled + or denied + :return: the return value indicates if the capability negotiation is + complete for this request or not + """ + + +class capability: + """Decorate a function to request a capability and handle the result. + + :param name: name of the capability to negotiate with the server; this + positional argument can be used multiple times to form a + single ``CAP REQ`` + :param handler: optional keyword argument, acknowledgement handler + + The Client Capability Negotiation is a feature of IRCv3 that exposes a + mechanism for a server to advertise a list of features and for clients to + request them when they are available. + + This decorator will register a capability request, allowing the bot to + request capabilities if they are available. You can request more than one + at a time, which will make for one single request. + + The handler must follow the :class:`CapabilityHandler` protocol. .. note:: diff --git a/sopel/plugins/rules.py b/sopel/plugins/rules.py index a40b41ae66..fa2ad833d7 100644 --- a/sopel/plugins/rules.py +++ b/sopel/plugins/rules.py @@ -54,6 +54,18 @@ ] TypedRule = TypeVar('TypedRule', bound='AbstractRule') +"""A :class:`~typing.TypeVar` bound to :class:`AbstractRule`. + +When used in the :meth:`AbstractRule.from_callable` class method, it means the +return value must be an instance of the class used to call that method and not +a different subclass of ``AbstractRule``. + +.. versionadded:: 8.0 + + This ``TypeVar`` was added as part of a goal to start type-checking + Sopel and is not used at runtime. + +""" LOGGER = logging.getLogger(__name__) diff --git a/sopel/tools/identifiers.py b/sopel/tools/identifiers.py index 017aa0fd06..6b0937a995 100644 --- a/sopel/tools/identifiers.py +++ b/sopel/tools/identifiers.py @@ -38,6 +38,7 @@ from typing import Callable Casemapping = Callable[[str], str] +"""Type definition of a casemapping callable.""" ASCII_TABLE = str.maketrans(string.ascii_uppercase, string.ascii_lowercase) RFC1459_TABLE = str.maketrans( @@ -278,3 +279,7 @@ def is_nick(self) -> bool: """ return bool(self) and not self.startswith(self.chantypes) + + +IdentifierFactory = Callable[[str], Identifier] +"""Type definition of an identifier factory.""" diff --git a/sopel/tools/memories.py b/sopel/tools/memories.py index ab79922a2d..5e1e844837 100644 --- a/sopel/tools/memories.py +++ b/sopel/tools/memories.py @@ -8,14 +8,9 @@ from collections import defaultdict import threading -from typing import TYPE_CHECKING +from typing import Optional -from .identifiers import Identifier - -if TYPE_CHECKING: - from typing import Callable, Optional - - IdentifierFactory = Callable[[str], Identifier] +from .identifiers import Identifier, IdentifierFactory class SopelMemory(dict): @@ -150,7 +145,7 @@ def __init__( identifier_factory: IdentifierFactory = Identifier, ) -> None: super().__init__(*args) - self.make_identifier = identifier_factory + self.make_identifier: IdentifierFactory = identifier_factory """A factory to transform keys into identifiers.""" def _make_key(self, key: Optional[str]) -> Optional[Identifier]: diff --git a/sopel/tools/target.py b/sopel/tools/target.py index 8c4e6d1349..f7f34d0574 100644 --- a/sopel/tools/target.py +++ b/sopel/tools/target.py @@ -1,16 +1,15 @@ +"""User and channel objects used in state tracking.""" from __future__ import annotations import functools -from typing import Any, Callable, Optional, TYPE_CHECKING, Union +from typing import Any, Optional, TYPE_CHECKING, Union from sopel import privileges -from sopel.tools import identifiers, memories +from sopel.tools import memories +from sopel.tools.identifiers import Identifier, IdentifierFactory if TYPE_CHECKING: - from datetime import datetime - - -IdentifierFactory = Callable[[str], identifiers.Identifier] + import datetime @functools.total_ordering @@ -28,12 +27,12 @@ class User: def __init__( self, - nick: identifiers.Identifier, + nick: Identifier, user: Optional[str], host: Optional[str], ) -> None: - assert isinstance(nick, identifiers.Identifier) - self.nick: identifiers.Identifier = nick + assert isinstance(nick, Identifier) + self.nick: Identifier = nick """The user's nickname.""" self.user: Optional[str] = user """The user's local username. @@ -53,7 +52,7 @@ def __init__( Will be ``None`` if Sopel has not yet received complete user information from the IRC server. """ - self.channels: dict[identifiers.Identifier, 'Channel'] = {} + self.channels: dict[Identifier, 'Channel'] = {} """The channels the user is in. This maps channel name :class:`~sopel.tools.identifiers.Identifier`\\s @@ -123,11 +122,11 @@ class Channel: def __init__( self, - name: identifiers.Identifier, - identifier_factory: IdentifierFactory = identifiers.Identifier, + name: Identifier, + identifier_factory: IdentifierFactory = Identifier, ) -> None: - assert isinstance(name, identifiers.Identifier) - self.name: identifiers.Identifier = name + assert isinstance(name, Identifier) + self.name: Identifier = name """The name of the channel.""" self.make_identifier: IdentifierFactory = identifier_factory @@ -139,7 +138,7 @@ def __init__( """ self.users: dict[ - identifiers.Identifier, + Identifier, User, ] = memories.SopelIdentifierMemory( identifier_factory=self.make_identifier, @@ -150,7 +149,7 @@ def __init__( :class:`User` objects. """ self.privileges: dict[ - identifiers.Identifier, + Identifier, int, ] = memories.SopelIdentifierMemory( identifier_factory=self.make_identifier, @@ -177,17 +176,17 @@ def __init__( does not automatically populate all modes and lists. """ - self.last_who: Optional[datetime] = None + self.last_who: Optional[datetime.datetime] = None """The last time a WHO was requested for the channel.""" - self.join_time: Optional[datetime] = None + self.join_time: Optional[datetime.datetime] = None """The time the server acknowledged our JOIN message. Based on server-reported time if the ``server-time`` IRCv3 capability is available, otherwise the time Sopel received it. """ - def clear_user(self, nick: identifiers.Identifier) -> None: + def clear_user(self, nick: Identifier) -> None: """Remove ``nick`` from this channel. :param nick: the nickname of the user to remove @@ -426,11 +425,7 @@ def is_voiced(self, nick: str) -> bool: identifier = self.make_identifier(nick) return bool(self.privileges.get(identifier, 0) & privileges.VOICE) - def rename_user( - self, - old: identifiers.Identifier, - new: identifiers.Identifier, - ) -> None: + def rename_user(self, old: Identifier, new: Identifier) -> None: """Rename a user. :param old: the user's old nickname diff --git a/sopel/trigger.py b/sopel/trigger.py index 97fb09c7a3..3f04040daa 100644 --- a/sopel/trigger.py +++ b/sopel/trigger.py @@ -12,7 +12,6 @@ from datetime import datetime, timezone import re from typing import ( - Callable, cast, Match, Optional, @@ -21,7 +20,8 @@ ) from sopel import formatting, tools -from sopel.tools import identifiers, web +from sopel.tools import web +from sopel.tools.identifiers import Identifier, IdentifierFactory if TYPE_CHECKING: from sopel import config @@ -32,10 +32,6 @@ 'Trigger', ] - -IdentifierFactory = Callable[[str], identifiers.Identifier] - - COMMANDS_WITH_CONTEXT = frozenset({ 'INVITE', 'JOIN', @@ -163,13 +159,13 @@ class PreTrigger: def __init__( self, - own_nick: identifiers.Identifier, + own_nick: Identifier, line: str, url_schemes: Optional[Sequence] = None, - identifier_factory: IdentifierFactory = identifiers.Identifier, + identifier_factory: IdentifierFactory = Identifier, statusmsg_prefixes: tuple[str, ...] = tuple(), ): - self.make_identifier = identifier_factory + self.make_identifier: IdentifierFactory = identifier_factory line = line.strip('\r\n') self.line: str = line self.urls: tuple[str, ...] = tuple() @@ -235,11 +231,11 @@ def __init__( components_match = cast( Match, PreTrigger.component_regex.match(self.hostmask or '')) nick, self.user, self.host = components_match.groups() - self.nick: identifiers.Identifier = self.make_identifier(nick) + self.nick: Identifier = self.make_identifier(nick) # If we have arguments, the first one is *usually* the sender, # most numerics and certain general events (e.g. QUIT) excepted - target: Optional[identifiers.Identifier] = None + target: Optional[Identifier] = None status_prefix: Optional[str] = None if self.args and self.event in COMMANDS_WITH_CONTEXT: