Skip to content
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
13 changes: 13 additions & 0 deletions docs/exts/commands/core.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:
30 changes: 29 additions & 1 deletion docs/getting-started/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions twitchio/ext/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 *
2 changes: 0 additions & 2 deletions twitchio/ext/commands/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 *

Expand Down Expand Up @@ -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

Expand Down
156 changes: 136 additions & 20 deletions twitchio/ext/commands/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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] = []

Expand All @@ -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,
}
23 changes: 17 additions & 6 deletions twitchio/ext/commands/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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__)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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

Expand Down