Skip to content

Commit 399ee7c

Browse files
authored
Feature/command converters (#507)
* Add cusomt command argument converters * Export new converters * Add docs * Fix an incorrect ruff adjustment * Add ColourConverters to default mapping * Add changelog * Adjust changelog
1 parent eeaa7c2 commit 399ee7c

File tree

6 files changed

+196
-29
lines changed

6 files changed

+196
-29
lines changed

docs/exts/commands/core.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,16 @@ Cooldowns
8585

8686
.. attributetable:: twitchio.ext.commands.BucketType()
8787
.. autoclass:: twitchio.ext.commands.BucketType()
88+
89+
90+
Converters
91+
##########
92+
93+
.. autoclass:: twitchio.ext.commands.Converter()
94+
:members:
95+
96+
.. autoclass:: twitchio.ext.commands.UserConverter()
97+
:members:
98+
99+
.. autoclass:: twitchio.ext.commands.ColourConverter()
100+
:members:

docs/getting-started/changelog.rst

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,35 @@
66
Changelog
77
##########
88

9-
3.0.0b
9+
3.1.0
10+
=====
11+
12+
- twitchio
13+
- Additions
14+
- Added ``__hash__`` to :class:`twitchio.PartialUser` allowing it to be used as a key.
15+
16+
- Changes
17+
- Adjusted the Starlette logging warning wording.
18+
- :class:`twitchio.PartialUser`, :class:`twitchio.User` and :class:`twitchio.Chatter` now have ``__hash__`` implementations derived from :class:`~twitchio.PartialUser`, which use the unique ID.
19+
20+
- Bug fixes
21+
- :meth:`twitchio.Clip.fetch_video` now properly returns ``None`` when the :class:`twitchio.Clip` has no ``video_id``.
22+
- :class:`twitchio.ChatterColor` no longer errors whan no valid hex is provided by Twitch.
23+
24+
- ext.commands
25+
- Additions
26+
- Added :class:`~twitchio.ext.commands.Converter`
27+
- Added :class:`~twitchio.ext.commands.UserConverter`
28+
- Added :class:`~twitchio.ext.commands.ColourConverter`
29+
- Added :class:`~twitchio.ext.commands.ColorConverter` alias.
30+
- Added :attr:`twitchio.ext.commands.Command.help` which is the docstring of the command callback.
31+
- Added ``__doc__`` to :class:`~twitchio.ext.commands.Command` which takes from the callback ``__doc__``.
32+
- Added :meth:`twitchio.ext.commands.Command.run_guards`
33+
- Added :meth:`twitchio.ext.commands.Context.fetch_command`
34+
- :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`.
35+
36+
37+
3.0.0
1038
======
1139

1240
The changelog for this version is too large to display. Please see :ref:`Migrating Guide` for more information.

twitchio/ext/commands/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from .bot import AutoBot as AutoBot, Bot as Bot
2626
from .components import *
2727
from .context import *
28+
from .converters import *
2829
from .cooldowns import *
2930
from .core import *
3031
from .exceptions import *

twitchio/ext/commands/bot.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@
3636

3737
from ...utils import _is_submodule
3838
from .context import Context
39-
from .converters import _BaseConverter
4039
from .core import Command, CommandErrorPayload, Group, Mixin
4140
from .exceptions import *
4241

@@ -176,7 +175,6 @@ def __init__(
176175
self._owner_id: str | None = owner_id
177176
self._get_prefix: PrefixT = prefix
178177
self._components: dict[str, Component] = {}
179-
self._base_converter: _BaseConverter = _BaseConverter(self)
180178
self.__modules: dict[str, types.ModuleType] = {}
181179
self._owner: User | None = None
182180

twitchio/ext/commands/converters.py

Lines changed: 136 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -24,19 +24,20 @@
2424

2525
from __future__ import annotations
2626

27-
from typing import TYPE_CHECKING, Any
27+
from typing import TYPE_CHECKING, Any, Protocol, TypeVar, runtime_checkable
2828

2929
from twitchio.user import User
30+
from twitchio.utils import Color, Colour
3031

3132
from .exceptions import *
3233

3334

3435
if TYPE_CHECKING:
35-
from .bot import Bot
3636
from .context import Context
37-
from .types_ import BotT
3837

39-
__all__ = ("_BaseConverter",)
38+
39+
__all__ = ("ColorConverter", "ColourConverter", "Converter", "UserConverter")
40+
4041

4142
_BOOL_MAPPING: dict[str, bool] = {
4243
"true": True,
@@ -52,38 +53,86 @@
5253
}
5354

5455

55-
class _BaseConverter:
56-
def __init__(self, client: Bot) -> None:
57-
self.__client: Bot = client
56+
T_co = TypeVar("T_co", covariant=True)
5857

59-
self._MAPPING: dict[Any, Any] = {User: self._user}
60-
self._DEFAULTS: dict[type, Any] = {str: str, int: int, float: float, bool: self._bool, type(None): type(None)}
6158

62-
def _bool(self, arg: str) -> bool:
63-
try:
64-
result = _BOOL_MAPPING[arg.lower()]
65-
except KeyError:
66-
pretty: str = " | ".join(f'"{k}"' for k in _BOOL_MAPPING)
67-
raise BadArgument(f'Failed to convert "{arg}" to type bool. Expected any: [{pretty}]', value=arg)
59+
@runtime_checkable
60+
class Converter(Protocol[T_co]):
61+
"""Base class used to create custom argument converters in :class:`~twitchio.ext.commands.Command`'s.
6862
69-
return result
63+
To create a custom converter and do conversion logic on an argument you must override the :meth:`.convert` method.
64+
:meth:`.convert` must be a coroutine.
65+
66+
Examples
67+
--------
68+
69+
.. code:: python3
70+
71+
class LowerCaseConverter(commands.Converter[str]):
72+
73+
async def convert(self, ctx: commands.Context, arg: str) -> str:
74+
return arg.lower()
75+
76+
77+
@commands.command()
78+
async def test(ctx: commands.Context, arg: LowerCaseConverter) -> None: ...
79+
80+
81+
.. versionadded:: 3.1
82+
"""
83+
84+
async def convert(self, ctx: Context[Any], arg: str) -> T_co:
85+
"""|coro|
86+
87+
Method used on converters to implement conversion logic.
88+
89+
Parameters
90+
----------
91+
ctx: :class:`~twitchio.ext.commands.Context`
92+
The context provided to the converter after command invocation has started.
93+
arg: str
94+
The argument received in raw form as a :class:`str` and passed to the converter to do conversion logic on.
95+
"""
96+
raise NotImplementedError("Classes that derive from Converter must implement this method.")
97+
98+
99+
class UserConverter(Converter[User]):
100+
"""The converter used to convert command arguments to a :class:`twitchio.User`.
101+
102+
This is a default converter which can be used in commands by annotating arguments with the :class:`twitchio.User` type.
103+
104+
.. note::
105+
106+
This converter uses an API call to attempt to fetch a valid :class:`twitchio.User`.
107+
108+
109+
Example
110+
-------
111+
112+
.. code:: python3
113+
114+
@commands.command()
115+
async def test(ctx: commands.Context, *, user: twitchio.User) -> None: ...
116+
"""
117+
118+
async def convert(self, ctx: Context[Any], arg: str) -> User:
119+
client = ctx.bot
70120

71-
async def _user(self, context: Context[BotT], arg: str) -> User:
72121
arg = arg.lower()
73122
users: list[User]
74123
msg: str = 'Failed to convert "{}" to User. A User with the ID or login could not be found.'
75124

76125
if arg.startswith("@"):
77126
arg = arg.removeprefix("@")
78-
users = await self.__client.fetch_users(logins=[arg])
127+
users = await client.fetch_users(logins=[arg])
79128

80129
if not users:
81130
raise BadArgument(msg.format(arg), value=arg)
82131

83132
if arg.isdigit():
84-
users = await self.__client.fetch_users(logins=[arg], ids=[arg])
133+
users = await client.fetch_users(logins=[arg], ids=[arg])
85134
else:
86-
users = await self.__client.fetch_users(logins=[arg])
135+
users = await client.fetch_users(logins=[arg])
87136

88137
potential: list[User] = []
89138

@@ -99,3 +148,70 @@ async def _user(self, context: Context[BotT], arg: str) -> User:
99148
return potential[0]
100149

101150
raise BadArgument(msg.format(arg), value=arg)
151+
152+
153+
class ColourConverter(Converter[Colour]):
154+
"""The converter used to convert command arguments to a :class:`~twitchio.utils.Colour` object.
155+
156+
This is a default converter which can be used in commands by annotating arguments with the :class:`twitchio.utils.Colour` type.
157+
158+
This converter, attempts to convert ``hex`` and ``int`` type values only in the following formats:
159+
160+
- `"#FFDD00"`
161+
- `"FFDD00"`
162+
- `"0xFFDD00"`
163+
- `16768256`
164+
165+
166+
``hex`` values are attempted first, followed by ``int``.
167+
168+
.. note::
169+
170+
There is an alias to this converter named ``ColorConverter``.
171+
172+
Example
173+
-------
174+
175+
.. code:: python3
176+
177+
@commands.command()
178+
async def test(ctx: commands.Context, *, colour: twitchio.utils.Colour) -> None: ...
179+
180+
.. versionadded:: 3.1
181+
"""
182+
183+
async def convert(self, ctx: Context[Any], arg: str) -> Colour:
184+
try:
185+
result = Colour.from_hex(arg)
186+
except Exception:
187+
pass
188+
else:
189+
return result
190+
191+
try:
192+
result = Colour.from_int(int(arg))
193+
except Exception:
194+
raise ConversionError(f"Unable to convert to Colour. {arg!r} is not a valid hex or colour integer value.")
195+
196+
return result
197+
198+
199+
ColorConverter = ColourConverter
200+
201+
202+
def _bool(arg: str) -> bool:
203+
try:
204+
result = _BOOL_MAPPING[arg.lower()]
205+
except KeyError:
206+
pretty: str = " | ".join(f'"{k}"' for k in _BOOL_MAPPING)
207+
raise BadArgument(f'Failed to convert "{arg}" to type bool. Expected any: [{pretty}]', value=arg)
208+
209+
return result
210+
211+
212+
DEFAULT_CONVERTERS: dict[type, Any] = {str: str, int: int, float: float, bool: _bool, type(None): type(None)}
213+
CONVERTER_MAPPING: dict[Any, Converter[Any] | type[Converter[Any]]] = {
214+
User: UserConverter,
215+
Colour: ColourConverter,
216+
Color: ColourConverter,
217+
}

twitchio/ext/commands/core.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import twitchio
3737
from twitchio.utils import MISSING, unwrap_function
3838

39+
from .converters import CONVERTER_MAPPING, DEFAULT_CONVERTERS, Converter
3940
from .cooldowns import BaseCooldown, Bucket, BucketType, Cooldown, KeyT
4041
from .exceptions import *
4142
from .types_ import CommandOptions, Component_T
@@ -359,7 +360,7 @@ def _convert_literal_type(
359360

360361
for arg in reversed(args):
361362
type_: type[Any] = type(arg) # type: ignore
362-
if base := context.bot._base_converter._DEFAULTS.get(type_):
363+
if base := DEFAULT_CONVERTERS.get(type_):
363364
try:
364365
result = base(raw)
365366
except Exception:
@@ -377,6 +378,7 @@ async def _do_conversion(
377378
self, context: Context[BotT], param: inspect.Parameter, *, annotation: Any, raw: str | None
378379
) -> Any:
379380
name: str = param.name
381+
result: Any = MISSING
380382

381383
if isinstance(annotation, UnionType) or getattr(annotation, "__origin__", None) is Union:
382384
converters = list(annotation.__args__)
@@ -386,8 +388,6 @@ async def _do_conversion(
386388
except ValueError:
387389
pass
388390

389-
result: Any = MISSING
390-
391391
for c in reversed(converters):
392392
try:
393393
result = await self._do_conversion(context, param=param, annotation=c, raw=raw)
@@ -414,7 +414,7 @@ async def _do_conversion(
414414

415415
return result
416416

417-
base = context.bot._base_converter._DEFAULTS.get(annotation, None if annotation != param.empty else str)
417+
base = DEFAULT_CONVERTERS.get(annotation, None if annotation != param.empty else str)
418418
if base:
419419
try:
420420
result = base(raw)
@@ -423,13 +423,24 @@ async def _do_conversion(
423423

424424
return result
425425

426-
converter = context.bot._base_converter._MAPPING.get(annotation, annotation)
426+
converter = CONVERTER_MAPPING.get(annotation, annotation)
427427

428428
try:
429-
result = converter(context, raw)
429+
if inspect.isclass(converter) and issubclass(converter, Converter): # type: ignore
430+
if inspect.ismethod(converter.convert):
431+
result = converter.convert(context, raw)
432+
else:
433+
result = converter().convert(context, str(raw))
434+
elif isinstance(converter, Converter):
435+
result = converter.convert(context, str(raw))
436+
except CommandError:
437+
raise
430438
except Exception as e:
431439
raise BadArgument(f'Failed to convert "{name}" to {type(converter)}', name=name, value=raw) from e
432440

441+
if result is MISSING:
442+
raise BadArgument(f'Failed to convert "{name}" to {type(converter)}', name=name, value=raw)
443+
433444
if not asyncio.iscoroutine(result):
434445
return result
435446

0 commit comments

Comments
 (0)