diff --git a/docs/exts/commands/core.rst b/docs/exts/commands/core.rst index 85c46921..6d5a45c1 100644 --- a/docs/exts/commands/core.rst +++ b/docs/exts/commands/core.rst @@ -85,3 +85,16 @@ Cooldowns .. attributetable:: twitchio.ext.commands.BucketType() .. autoclass:: twitchio.ext.commands.BucketType() + + +Converters +########## + +.. autoclass:: twitchio.ext.commands.Converter() + :members: + +.. autoclass:: twitchio.ext.commands.UserConverter() + :members: + +.. autoclass:: twitchio.ext.commands.ColourConverter() + :members: \ No newline at end of file diff --git a/docs/getting-started/changelog.rst b/docs/getting-started/changelog.rst index 85c0ef86..0564766e 100644 --- a/docs/getting-started/changelog.rst +++ b/docs/getting-started/changelog.rst @@ -6,7 +6,35 @@ Changelog ########## -3.0.0b +3.1.0 +===== + +- twitchio + - Additions + - Added ``__hash__`` to :class:`twitchio.PartialUser` allowing it to be used as a key. + + - Changes + - Adjusted the Starlette logging warning wording. + - :class:`twitchio.PartialUser`, :class:`twitchio.User` and :class:`twitchio.Chatter` now have ``__hash__`` implementations derived from :class:`~twitchio.PartialUser`, which use the unique ID. + + - Bug fixes + - :meth:`twitchio.Clip.fetch_video` now properly returns ``None`` when the :class:`twitchio.Clip` has no ``video_id``. + - :class:`twitchio.ChatterColor` no longer errors whan no valid hex is provided by Twitch. + +- ext.commands + - Additions + - Added :class:`~twitchio.ext.commands.Converter` + - Added :class:`~twitchio.ext.commands.UserConverter` + - Added :class:`~twitchio.ext.commands.ColourConverter` + - Added :class:`~twitchio.ext.commands.ColorConverter` alias. + - Added :attr:`twitchio.ext.commands.Command.help` which is the docstring of the command callback. + - Added ``__doc__`` to :class:`~twitchio.ext.commands.Command` which takes from the callback ``__doc__``. + - Added :meth:`twitchio.ext.commands.Command.run_guards` + - Added :meth:`twitchio.ext.commands.Context.fetch_command` + - :class:`~twitchio.ext.commands.Context` is now ``Generic`` and accepts a generic argument bound to :class:`~twitchio.ext.commands.Bot` or :class:`~twitchio.ext.commands.AutoBot`. + + +3.0.0 ====== The changelog for this version is too large to display. Please see :ref:`Migrating Guide` for more information. diff --git a/twitchio/ext/commands/__init__.py b/twitchio/ext/commands/__init__.py index 41797ab1..58fd5a6d 100644 --- a/twitchio/ext/commands/__init__.py +++ b/twitchio/ext/commands/__init__.py @@ -25,6 +25,7 @@ from .bot import AutoBot as AutoBot, Bot as Bot from .components import * from .context import * +from .converters import * from .cooldowns import * from .core import * from .exceptions import * diff --git a/twitchio/ext/commands/bot.py b/twitchio/ext/commands/bot.py index d5aa4b71..e7365ee0 100644 --- a/twitchio/ext/commands/bot.py +++ b/twitchio/ext/commands/bot.py @@ -36,7 +36,6 @@ from ...utils import _is_submodule from .context import Context -from .converters import _BaseConverter from .core import Command, CommandErrorPayload, Group, Mixin from .exceptions import * @@ -176,7 +175,6 @@ def __init__( self._owner_id: str | None = owner_id self._get_prefix: PrefixT = prefix self._components: dict[str, Component] = {} - self._base_converter: _BaseConverter = _BaseConverter(self) self.__modules: dict[str, types.ModuleType] = {} self._owner: User | None = None diff --git a/twitchio/ext/commands/converters.py b/twitchio/ext/commands/converters.py index cd176f36..8b5a8bb5 100644 --- a/twitchio/ext/commands/converters.py +++ b/twitchio/ext/commands/converters.py @@ -24,19 +24,20 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Protocol, TypeVar, runtime_checkable from twitchio.user import User +from twitchio.utils import Color, Colour from .exceptions import * if TYPE_CHECKING: - from .bot import Bot from .context import Context - from .types_ import BotT -__all__ = ("_BaseConverter",) + +__all__ = ("ColorConverter", "ColourConverter", "Converter", "UserConverter") + _BOOL_MAPPING: dict[str, bool] = { "true": True, @@ -52,38 +53,86 @@ } -class _BaseConverter: - def __init__(self, client: Bot) -> None: - self.__client: Bot = client +T_co = TypeVar("T_co", covariant=True) - self._MAPPING: dict[Any, Any] = {User: self._user} - self._DEFAULTS: dict[type, Any] = {str: str, int: int, float: float, bool: self._bool, type(None): type(None)} - def _bool(self, arg: str) -> bool: - try: - result = _BOOL_MAPPING[arg.lower()] - except KeyError: - pretty: str = " | ".join(f'"{k}"' for k in _BOOL_MAPPING) - raise BadArgument(f'Failed to convert "{arg}" to type bool. Expected any: [{pretty}]', value=arg) +@runtime_checkable +class Converter(Protocol[T_co]): + """Base class used to create custom argument converters in :class:`~twitchio.ext.commands.Command`'s. - return result + To create a custom converter and do conversion logic on an argument you must override the :meth:`.convert` method. + :meth:`.convert` must be a coroutine. + + Examples + -------- + + .. code:: python3 + + class LowerCaseConverter(commands.Converter[str]): + + async def convert(self, ctx: commands.Context, arg: str) -> str: + return arg.lower() + + + @commands.command() + async def test(ctx: commands.Context, arg: LowerCaseConverter) -> None: ... + + + .. versionadded:: 3.1 + """ + + async def convert(self, ctx: Context[Any], arg: str) -> T_co: + """|coro| + + Method used on converters to implement conversion logic. + + Parameters + ---------- + ctx: :class:`~twitchio.ext.commands.Context` + The context provided to the converter after command invocation has started. + arg: str + The argument received in raw form as a :class:`str` and passed to the converter to do conversion logic on. + """ + raise NotImplementedError("Classes that derive from Converter must implement this method.") + + +class UserConverter(Converter[User]): + """The converter used to convert command arguments to a :class:`twitchio.User`. + + This is a default converter which can be used in commands by annotating arguments with the :class:`twitchio.User` type. + + .. note:: + + This converter uses an API call to attempt to fetch a valid :class:`twitchio.User`. + + + Example + ------- + + .. code:: python3 + + @commands.command() + async def test(ctx: commands.Context, *, user: twitchio.User) -> None: ... + """ + + async def convert(self, ctx: Context[Any], arg: str) -> User: + client = ctx.bot - async def _user(self, context: Context[BotT], arg: str) -> User: arg = arg.lower() users: list[User] msg: str = 'Failed to convert "{}" to User. A User with the ID or login could not be found.' if arg.startswith("@"): arg = arg.removeprefix("@") - users = await self.__client.fetch_users(logins=[arg]) + users = await client.fetch_users(logins=[arg]) if not users: raise BadArgument(msg.format(arg), value=arg) if arg.isdigit(): - users = await self.__client.fetch_users(logins=[arg], ids=[arg]) + users = await client.fetch_users(logins=[arg], ids=[arg]) else: - users = await self.__client.fetch_users(logins=[arg]) + users = await client.fetch_users(logins=[arg]) potential: list[User] = [] @@ -99,3 +148,70 @@ async def _user(self, context: Context[BotT], arg: str) -> User: return potential[0] raise BadArgument(msg.format(arg), value=arg) + + +class ColourConverter(Converter[Colour]): + """The converter used to convert command arguments to a :class:`~twitchio.utils.Colour` object. + + This is a default converter which can be used in commands by annotating arguments with the :class:`twitchio.utils.Colour` type. + + This converter, attempts to convert ``hex`` and ``int`` type values only in the following formats: + + - `"#FFDD00"` + - `"FFDD00"` + - `"0xFFDD00"` + - `16768256` + + + ``hex`` values are attempted first, followed by ``int``. + + .. note:: + + There is an alias to this converter named ``ColorConverter``. + + Example + ------- + + .. code:: python3 + + @commands.command() + async def test(ctx: commands.Context, *, colour: twitchio.utils.Colour) -> None: ... + + .. versionadded:: 3.1 + """ + + async def convert(self, ctx: Context[Any], arg: str) -> Colour: + try: + result = Colour.from_hex(arg) + except Exception: + pass + else: + return result + + try: + result = Colour.from_int(int(arg)) + except Exception: + raise ConversionError(f"Unable to convert to Colour. {arg!r} is not a valid hex or colour integer value.") + + return result + + +ColorConverter = ColourConverter + + +def _bool(arg: str) -> bool: + try: + result = _BOOL_MAPPING[arg.lower()] + except KeyError: + pretty: str = " | ".join(f'"{k}"' for k in _BOOL_MAPPING) + raise BadArgument(f'Failed to convert "{arg}" to type bool. Expected any: [{pretty}]', value=arg) + + return result + + +DEFAULT_CONVERTERS: dict[type, Any] = {str: str, int: int, float: float, bool: _bool, type(None): type(None)} +CONVERTER_MAPPING: dict[Any, Converter[Any] | type[Converter[Any]]] = { + User: UserConverter, + Colour: ColourConverter, + Color: ColourConverter, +} diff --git a/twitchio/ext/commands/core.py b/twitchio/ext/commands/core.py index 0cd2c0ce..c2812999 100644 --- a/twitchio/ext/commands/core.py +++ b/twitchio/ext/commands/core.py @@ -36,6 +36,7 @@ import twitchio from twitchio.utils import MISSING, unwrap_function +from .converters import CONVERTER_MAPPING, DEFAULT_CONVERTERS, Converter from .cooldowns import BaseCooldown, Bucket, BucketType, Cooldown, KeyT from .exceptions import * from .types_ import CommandOptions, Component_T @@ -359,7 +360,7 @@ def _convert_literal_type( for arg in reversed(args): type_: type[Any] = type(arg) # type: ignore - if base := context.bot._base_converter._DEFAULTS.get(type_): + if base := DEFAULT_CONVERTERS.get(type_): try: result = base(raw) except Exception: @@ -377,6 +378,7 @@ async def _do_conversion( self, context: Context[BotT], param: inspect.Parameter, *, annotation: Any, raw: str | None ) -> Any: name: str = param.name + result: Any = MISSING if isinstance(annotation, UnionType) or getattr(annotation, "__origin__", None) is Union: converters = list(annotation.__args__) @@ -386,8 +388,6 @@ async def _do_conversion( except ValueError: pass - result: Any = MISSING - for c in reversed(converters): try: result = await self._do_conversion(context, param=param, annotation=c, raw=raw) @@ -414,7 +414,7 @@ async def _do_conversion( return result - base = context.bot._base_converter._DEFAULTS.get(annotation, None if annotation != param.empty else str) + base = DEFAULT_CONVERTERS.get(annotation, None if annotation != param.empty else str) if base: try: result = base(raw) @@ -423,13 +423,24 @@ async def _do_conversion( return result - converter = context.bot._base_converter._MAPPING.get(annotation, annotation) + converter = CONVERTER_MAPPING.get(annotation, annotation) try: - result = converter(context, raw) + if inspect.isclass(converter) and issubclass(converter, Converter): # type: ignore + if inspect.ismethod(converter.convert): + result = converter.convert(context, raw) + else: + result = converter().convert(context, str(raw)) + elif isinstance(converter, Converter): + result = converter.convert(context, str(raw)) + except CommandError: + raise except Exception as e: raise BadArgument(f'Failed to convert "{name}" to {type(converter)}', name=name, value=raw) from e + if result is MISSING: + raise BadArgument(f'Failed to convert "{name}" to {type(converter)}', name=name, value=raw) + if not asyncio.iscoroutine(result): return result