From 761974efb056bbf2c6187896e4cad23e52f59db0 Mon Sep 17 00:00:00 2001 From: EvieePy <29671945+EvieePy@users.noreply.github.com> Date: Sun, 20 Jul 2025 21:14:08 +1000 Subject: [PATCH 1/7] Add cusomt command argument converters --- twitchio/ext/commands/__init__.py | 1 + twitchio/ext/commands/bot.py | 2 - twitchio/ext/commands/converters.py | 153 ++++++++++++++++++++++++---- twitchio/ext/commands/core.py | 24 +++-- 4 files changed, 152 insertions(+), 28 deletions(-) diff --git a/twitchio/ext/commands/__init__.py b/twitchio/ext/commands/__init__.py index 41797ab1..9d147800 100644 --- a/twitchio/ext/commands/__init__.py +++ b/twitchio/ext/commands/__init__.py @@ -28,3 +28,4 @@ from .cooldowns import * from .core import * from .exceptions import * +from .converters 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..6ad8d159 100644 --- a/twitchio/ext/commands/converters.py +++ b/twitchio/ext/commands/converters.py @@ -24,19 +24,21 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Protocol, TypeVar, runtime_checkable +from twitchio.ext.commands.context import Context from twitchio.user import User +from twitchio.utils import Colour from .exceptions import * if TYPE_CHECKING: - from .bot import Bot from .context import Context - from .types_ import BotT -__all__ = ("_BaseConverter",) + +__all__ = ("Converter", "UserConverter") + _BOOL_MAPPING: dict[str, bool] = { "true": True, @@ -52,38 +54,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 +149,66 @@ 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} diff --git a/twitchio/ext/commands/core.py b/twitchio/ext/commands/core.py index 0cd2c0ce..a8fe0920 100644 --- a/twitchio/ext/commands/core.py +++ b/twitchio/ext/commands/core.py @@ -40,6 +40,8 @@ from .exceptions import * from .types_ import CommandOptions, Component_T +from .converters import DEFAULT_CONVERTERS, CONVERTER_MAPPING, Converter + __all__ = ( "Command", @@ -359,7 +361,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 +379,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 +389,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 +415,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 +424,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 From 4a7d806e4f30aa6b43e01ba25d06430653f3be01 Mon Sep 17 00:00:00 2001 From: EvieePy <29671945+EvieePy@users.noreply.github.com> Date: Sun, 20 Jul 2025 21:16:42 +1000 Subject: [PATCH 2/7] Export new converters --- twitchio/ext/commands/__init__.py | 2 +- twitchio/ext/commands/converters.py | 2 +- twitchio/ext/commands/core.py | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/twitchio/ext/commands/__init__.py b/twitchio/ext/commands/__init__.py index 9d147800..58fd5a6d 100644 --- a/twitchio/ext/commands/__init__.py +++ b/twitchio/ext/commands/__init__.py @@ -25,7 +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 * -from .converters import * diff --git a/twitchio/ext/commands/converters.py b/twitchio/ext/commands/converters.py index 6ad8d159..b46db555 100644 --- a/twitchio/ext/commands/converters.py +++ b/twitchio/ext/commands/converters.py @@ -37,7 +37,7 @@ from .context import Context -__all__ = ("Converter", "UserConverter") +__all__ = ("ColorConverter", "ColourConverter", "Converter", "UserConverter") _BOOL_MAPPING: dict[str, bool] = { diff --git a/twitchio/ext/commands/core.py b/twitchio/ext/commands/core.py index a8fe0920..c2812999 100644 --- a/twitchio/ext/commands/core.py +++ b/twitchio/ext/commands/core.py @@ -36,12 +36,11 @@ 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 -from .converters import DEFAULT_CONVERTERS, CONVERTER_MAPPING, Converter - __all__ = ( "Command", From e9781585aa3ba7dc5c542c9bae6ecf745fcefdb7 Mon Sep 17 00:00:00 2001 From: EvieePy <29671945+EvieePy@users.noreply.github.com> Date: Sun, 20 Jul 2025 21:16:52 +1000 Subject: [PATCH 3/7] Add docs --- docs/exts/commands/core.rst | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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 From ad619e3a047e66cbdf77f63f9772d61f36d3bc2c Mon Sep 17 00:00:00 2001 From: EvieePy <29671945+EvieePy@users.noreply.github.com> Date: Sun, 20 Jul 2025 21:20:50 +1000 Subject: [PATCH 4/7] Fix an incorrect ruff adjustment --- twitchio/ext/commands/converters.py | 1 - 1 file changed, 1 deletion(-) diff --git a/twitchio/ext/commands/converters.py b/twitchio/ext/commands/converters.py index b46db555..b633eefb 100644 --- a/twitchio/ext/commands/converters.py +++ b/twitchio/ext/commands/converters.py @@ -26,7 +26,6 @@ from typing import TYPE_CHECKING, Any, Protocol, TypeVar, runtime_checkable -from twitchio.ext.commands.context import Context from twitchio.user import User from twitchio.utils import Colour From 695bca7f59746ef0218944df6773ed3b42a71580 Mon Sep 17 00:00:00 2001 From: EvieePy <29671945+EvieePy@users.noreply.github.com> Date: Sun, 20 Jul 2025 21:27:19 +1000 Subject: [PATCH 5/7] Add ColourConverters to default mapping --- twitchio/ext/commands/converters.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/twitchio/ext/commands/converters.py b/twitchio/ext/commands/converters.py index b633eefb..8b5a8bb5 100644 --- a/twitchio/ext/commands/converters.py +++ b/twitchio/ext/commands/converters.py @@ -27,7 +27,7 @@ from typing import TYPE_CHECKING, Any, Protocol, TypeVar, runtime_checkable from twitchio.user import User -from twitchio.utils import Colour +from twitchio.utils import Color, Colour from .exceptions import * @@ -210,4 +210,8 @@ def _bool(arg: str) -> bool: 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} +CONVERTER_MAPPING: dict[Any, Converter[Any] | type[Converter[Any]]] = { + User: UserConverter, + Colour: ColourConverter, + Color: ColourConverter, +} From b1a8201f027b8a6619384ac577e7a526eaa1a5c3 Mon Sep 17 00:00:00 2001 From: EvieePy <29671945+EvieePy@users.noreply.github.com> Date: Sun, 20 Jul 2025 21:45:27 +1000 Subject: [PATCH 6/7] Add changelog --- docs/getting-started/changelog.rst | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/docs/getting-started/changelog.rst b/docs/getting-started/changelog.rst index 85c0ef86..108b5966 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, 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` + - 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_gaurds` + - 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. From fa918ede9324f39bd0a1d6b9dd2af1072eca8207 Mon Sep 17 00:00:00 2001 From: EvieePy <29671945+EvieePy@users.noreply.github.com> Date: Sun, 20 Jul 2025 21:49:29 +1000 Subject: [PATCH 7/7] Adjust changelog --- docs/getting-started/changelog.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/getting-started/changelog.rst b/docs/getting-started/changelog.rst index 108b5966..0564766e 100644 --- a/docs/getting-started/changelog.rst +++ b/docs/getting-started/changelog.rst @@ -15,7 +15,7 @@ Changelog - Changes - Adjusted the Starlette logging warning wording. - - :class:`twitchio.PartialUser`, :class:`twitchio.User` and :class:`twitchio.Chatter` now have ``__hash__`` implementations, which use the unique ID. + - :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``. @@ -26,11 +26,11 @@ Changelog - Added :class:`~twitchio.ext.commands.Converter` - Added :class:`~twitchio.ext.commands.UserConverter` - Added :class:`~twitchio.ext.commands.ColourConverter` - - Added :class:`~twitchio.ext.commands.ColorConverter` - - Added :attr:`~twitchio.ext.commands.Command.help` which is the docstring of the command callback. + - 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_gaurds` - - Added :meth:`~twitchio.ext.commands.Context.fetch_command` + - 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`.