Skip to content

Commit 20fe33b

Browse files
authored
Feature/translations (#513)
* Add initial Translator implementation * Add translator docs * Add Translator doc refs * Add Translator Error * Properly highlight None * Fix code blocks in Translator docs * Fix translator decorator ref links * Complete 3.1 changelog * Add reply_translated to Context * Fix changelog ref * Change if not to explicit None check in _translated methods * Add Generic to Translator and add translator property to Context
1 parent b9ac3ec commit 20fe33b

File tree

8 files changed

+434
-1
lines changed

8 files changed

+434
-1
lines changed

docs/exts/commands/core.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ Decorators
5454

5555
.. autofunction:: twitchio.ext.commands.cooldown(*, base: BaseCooldown, rate: int, per: float, key: Callable[[Any], Hashable] | Callable[[Any], Coroutine[Any, Any, Hashable]] | BucketType, **kwargs: ~typing.Any)
5656

57+
.. autofunction:: twitchio.ext.commands.translator
58+
5759

5860
Guards
5961
######
@@ -97,4 +99,11 @@ Converters
9799
:members:
98100

99101
.. autoclass:: twitchio.ext.commands.ColourConverter()
102+
:members:
103+
104+
105+
Translators
106+
###########
107+
108+
.. autoclass:: twitchio.ext.commands.Translator()
100109
:members:

docs/exts/commands/exceptions.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ Exceptions
5757

5858
.. autoexception:: twitchio.ext.commands.NoEntryPointError
5959

60+
.. autoexception:: twitchio.ext.commands.TranslatorError
61+
6062

6163
Exception Hierarchy
6264
~~~~~~~~~~~~~~~~~~~
@@ -80,6 +82,7 @@ Exception Hierarchy
8082
- :exc:`ExpectedClosingQuoteError`
8183
- :exc:`GuardFailure`
8284
- :exc:`CommandOnCooldown`
85+
- :exc:`TranslatorError`
8386
- :exc:`ModuleError`
8487
- :exc:`ModuleLoadFailure`
8588
- :exc:`ModuleAlreadyLoadedError`

docs/getting-started/changelog.rst

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,26 @@ Changelog
1212
- twitchio
1313
- Additions
1414
- Added ``__hash__`` to :class:`twitchio.PartialUser` allowing it to be used as a key.
15+
- Added the ``--create-new`` interactive script to ``__main__`` allowing boiler-plate to be generated for a new Bot.
1516

1617
- Changes
1718
- Adjusted the Starlette logging warning wording.
19+
- Delayed the Starlette logging warning and removed it from ``web/__init__.py``.
1820
- :class:`twitchio.PartialUser`, :class:`twitchio.User` and :class:`twitchio.Chatter` now have ``__hash__`` implementations derived from :class:`~twitchio.PartialUser`, which use the unique ID.
1921

2022
- Bug fixes
2123
- :meth:`twitchio.Clip.fetch_video` now properly returns ``None`` when the :class:`twitchio.Clip` has no ``video_id``.
2224
- :class:`twitchio.ChatterColor` no longer errors whan no valid hex is provided by Twitch.
25+
- Some general typing/spelling errors cleaned up in Documentation and Logging.
26+
- Removed some redundant logging.
27+
28+
- twitchio.AutoClient
29+
- Additions
30+
- Added ``force_subscribe`` keyword argument to :class:`twitchio.AutoClient`, allowing subscriptions passed to be made everytime the client is started.
31+
32+
- twitchio.ext.commands.AutoBot
33+
- Additions
34+
- Added ``force_subscribe`` keyword argument to :class:`twitchio.ext.commands.AutoBot`, allowing subscriptions passed to be made everytime the bot is started.
2335

2436
- twitchio.eventsub
2537
- Additions
@@ -82,13 +94,35 @@ Changelog
8294
- Added :meth:`twitchio.ShoutoutReceive.respond`
8395
- Added :meth:`twitchio.StreamOnline.respond`
8496
- Added :meth:`twitchio.StreamOffline.respond`
97+
98+
- Bug fixes
99+
- Remove the unnecessary ``token_for`` parameter from :meth:`twitchio.ChannelPointsReward.fetch_reward`. `#510 <https://github.com/PythonistaGuild/TwitchIO/pull/510>`_
100+
101+
- twitchio.web.AiohttpAdapter
102+
- Bug fixes
103+
- Fixed the redirect URL not allowing HOST/PORT when a custom domain was passed.
104+
- The redirect URL is now determined based on where the request came from.
105+
106+
- twitchio.web.StarletteAdapter
107+
- Bug fixes
108+
- Fixed the redirect URL not allowing HOST/PORT when a custom domain was passed.
109+
- The redirect URL is now determined based on where the request came from.
110+
- Fixed Uvicorn hanging the process when attempting to close the :class:`asyncio.Loop` on **Windows**.
111+
- After ``5 seconds`` Uvicorn will be forced closed if it cannot gracefully close in this time.
85112

86113
- ext.commands
87114
- Additions
115+
- Added :class:`~twitchio.ext.commands.Translator`
116+
- Added :func:`~twitchio.ext.commands.translator`
117+
- Added :attr:`twitchio.ext.commands.Command.translator`
118+
- Added :meth:`twitchio.ext.commands.Context.send_translated`
119+
- Added :meth:`twitchio.ext.commands.Context.reply_translated`
88120
- Added :class:`~twitchio.ext.commands.Converter`
89121
- Added :class:`~twitchio.ext.commands.UserConverter`
90122
- Added :class:`~twitchio.ext.commands.ColourConverter`
91123
- Added :class:`~twitchio.ext.commands.ColorConverter` alias.
124+
- Added :attr:`twitchio.ext.commands.Command.signature` which is a POSIX-like signature for the command.
125+
- Added :attr:`twitchio.ext.commands.Command.parameters` which is a mapping of parameter name to :class:`inspect.Parameter` associated with the command callback.
92126
- Added :attr:`twitchio.ext.commands.Command.help` which is the docstring of the command callback.
93127
- Added ``__doc__`` to :class:`~twitchio.ext.commands.Command` which takes from the callback ``__doc__``.
94128
- Added :meth:`twitchio.ext.commands.Command.run_guards`

twitchio/ext/commands/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,4 @@
2929
from .cooldowns import *
3030
from .core import *
3131
from .exceptions import *
32+
from .translators import *

twitchio/ext/commands/context.py

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
from .bot import Bot
4848
from .components import Component
4949
from .core import Command
50+
from .translators import Translator
5051

5152
PrefixT: TypeAlias = str | Iterable[str] | Callable[[Bot, ChatMessage], Coroutine[Any, Any, str | Iterable[str]]]
5253

@@ -255,6 +256,15 @@ def type(self) -> ContextType:
255256
"""
256257
return self._type
257258

259+
@property
260+
def translator(self) -> Translator[Any] | None:
261+
"""Property returning the :class:`.commands.Translator` assigned to the :class:`.commands.Command` if found or
262+
``None``. This property will always return ``None`` if no valid command or prefix is associated with this Context."""
263+
if not self.command:
264+
return None
265+
266+
return self.command.translator
267+
258268
@property
259269
def error_dispatched(self) -> bool:
260270
return self._error_dispatched
@@ -543,6 +553,80 @@ async def send(self, content: str, *, me: bool = False) -> SentMessage:
543553
new = (f"/me {content}" if me else content).strip()
544554
return await self.channel.send_message(sender=self.bot.bot_id, message=new)
545555

556+
async def send_translated(self, content: str, *, me: bool = False, langcode: str | None = None) -> SentMessage:
557+
"""|coro|
558+
559+
Send a translated chat message to the channel associated with this context.
560+
561+
You must have added a :class:`.commands.Translator` to your :class:`.commands.Command` in order to effectively use
562+
this method. If no :class:`.commands.Translator` is found, this method acts identical to :meth:`.send`.
563+
564+
If this method can not find a valid language code, E.g. both :meth:`.commands.Translator.get_langcode` and the parameter
565+
``langcode`` return ``None``, this method acts identical to :meth:`.send`.
566+
567+
See the following documentation for more details on translators:
568+
569+
- :class:`.commands.Translator`
570+
- :func:`.commands.translator`
571+
572+
.. important::
573+
574+
You must have the ``user:write:chat`` scope. If an app access token is used,
575+
then additionally requires the ``user:bot`` scope on the bot,
576+
and either ``channel:bot`` scope from the broadcaster or moderator status.
577+
578+
Parameters
579+
----------
580+
content: str
581+
The content of the message you would like to translate and then send.
582+
This **and** the translated version of this content cannot exceed ``500`` characters.
583+
Additionally the content parameter will be stripped of all leading and trailing whitespace.
584+
me: bool
585+
An optional bool indicating whether you would like to send this message with the ``/me`` chat command.
586+
langcode: str | None
587+
An optional ``langcode`` to override the ``langcode`` returned from :meth:`.commands.Translator.get_langcode`.
588+
This should only be provided if you do custom language code lookups outside of your
589+
:class:`.commands.Translator`. Defaults to ``None``.
590+
591+
592+
Returns
593+
-------
594+
SentMessage
595+
The payload received by Twitch after sending this message.
596+
597+
Raises
598+
------
599+
HTTPException
600+
Twitch failed to process the message, could be ``400``, ``401``, ``403``, ``422`` or any ``5xx`` status code.
601+
MessageRejectedError
602+
Twitch rejected the message from various checks.
603+
TranslatorError
604+
An error occurred during translation.
605+
"""
606+
translator: Translator[Any] | None = self.translator
607+
new = (f"/me {content}" if me else content).strip()
608+
609+
if not self.command or not translator:
610+
return await self.channel.send_message(sender=self.bot.bot_id, message=new)
611+
612+
invoked = self.invoked_with
613+
614+
try:
615+
code = langcode or translator.get_langcode(self, invoked.lower()) if invoked else None
616+
except Exception as e:
617+
raise TranslatorError(f"An exception occurred fetching a language code for '{invoked}'.", original=e) from e
618+
619+
if code is None:
620+
return await self.channel.send_message(sender=self.bot.bot_id, message=new)
621+
622+
try:
623+
translated = await translator.translate(self, content, code)
624+
except Exception as e:
625+
raise TranslatorError(f"An exception occurred translating content for '{invoked}'.", original=e) from e
626+
627+
new_translated = (f"/me {translated}" if me else translated).strip()
628+
return await self.channel.send_message(sender=self.bot.bot_id, message=new_translated)
629+
546630
async def reply(self, content: str, *, me: bool = False) -> SentMessage:
547631
"""|coro|
548632
@@ -588,6 +672,93 @@ async def reply(self, content: str, *, me: bool = False) -> SentMessage:
588672
new = (f"/me {content}" if me else content).strip()
589673
return await self.channel.send_message(sender=self.bot.bot_id, message=new, reply_to_message_id=self._payload.id)
590674

675+
async def reply_translated(self, content: str, *, me: bool = False, langcode: str | None = None) -> SentMessage:
676+
"""|coro|
677+
678+
Send a translated chat message as a reply to the user who this message is associated with and to the channel associated with
679+
this context.
680+
681+
You must have added a :class:`.commands.Translator` to your :class:`.commands.Command` in order to effectively use
682+
this method. If no :class:`.commands.Translator` is found, this method acts identical to :meth:`.reply`.
683+
684+
If this method can not find a valid language code, E.g. both :meth:`.commands.Translator.get_langcode` and the parameter
685+
``langcode`` return ``None``, this method acts identical to :meth:`.reply`.
686+
687+
See the following documentation for more details on translators:
688+
689+
- :class:`.commands.Translator`
690+
- :func:`.commands.translator`
691+
692+
.. warning::
693+
694+
You cannot use this method in Reward based context. E.g.
695+
if :attr:`~.commands.Context.type` is :attr:`~.commands.ContextType.REWARD`.
696+
697+
.. important::
698+
699+
You must have the ``user:write:chat`` scope. If an app access token is used,
700+
then additionally requires the ``user:bot`` scope on the bot,
701+
and either ``channel:bot`` scope from the broadcaster or moderator status.
702+
703+
Parameters
704+
----------
705+
content: str
706+
The content of the message you would like to translate and then send.
707+
This **and** the translated version of this content cannot exceed ``500`` characters.
708+
Additionally the content parameter will be stripped of all leading and trailing whitespace.
709+
me: bool
710+
An optional bool indicating whether you would like to send this message with the ``/me`` chat command.
711+
langcode: str | None
712+
An optional ``langcode`` to override the ``langcode`` returned from :meth:`.commands.Translator.get_langcode`.
713+
This should only be provided if you do custom language code lookups outside of your
714+
:class:`.commands.Translator`. Defaults to ``None``.
715+
716+
717+
Returns
718+
-------
719+
SentMessage
720+
The payload received by Twitch after sending this message.
721+
722+
Raises
723+
------
724+
HTTPException
725+
Twitch failed to process the message, could be ``400``, ``401``, ``403``, ``422`` or any ``5xx`` status code.
726+
MessageRejectedError
727+
Twitch rejected the message from various checks.
728+
TranslatorError
729+
An error occurred during translation.
730+
"""
731+
if self._type is ContextType.REWARD:
732+
raise TypeError("Cannot reply to a message in a Reward based context.")
733+
734+
translator: Translator[Any] | None = self.translator
735+
new = (f"/me {content}" if me else content).strip()
736+
737+
if not self.command or not translator:
738+
return await self.channel.send_message(sender=self.bot.bot_id, message=new, reply_to_message_id=self._payload.id)
739+
740+
invoked = self.invoked_with
741+
742+
try:
743+
code = langcode or translator.get_langcode(self, invoked.lower()) if invoked else None
744+
except Exception as e:
745+
raise TranslatorError(f"An exception occurred fetching a language code for '{invoked}'.", original=e) from e
746+
747+
if code is None:
748+
return await self.channel.send_message(sender=self.bot.bot_id, message=new, reply_to_message_id=self._payload.id)
749+
750+
try:
751+
translated = await translator.translate(self, content, code)
752+
except Exception as e:
753+
raise TranslatorError(f"An exception occurred translating content for '{invoked}'.", original=e) from e
754+
755+
new_translated = (f"/me {translated}" if me else translated).strip()
756+
return await self.channel.send_message(
757+
sender=self.bot.bot_id,
758+
message=new_translated,
759+
reply_to_message_id=self._payload.id,
760+
)
761+
591762
async def send_announcement(
592763
self, content: str, *, color: Literal["blue", "green", "orange", "purple", "primary"] | None = None
593764
) -> None:

twitchio/ext/commands/core.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,13 +61,15 @@
6161
"is_staff",
6262
"is_vip",
6363
"reward_command",
64+
"translator",
6465
)
6566

6667

6768
if TYPE_CHECKING:
6869
from twitchio.user import Chatter
6970

7071
from .context import Context
72+
from .translators import Translator
7173
from .types_ import BotT
7274

7375
P = ParamSpec("P")
@@ -223,6 +225,12 @@ def __init__(
223225
self._before_hook: Callable[[Component_T, Context[Any]], Coro] | Callable[[Context[Any]], Coro] | None = None
224226
self._after_hook: Callable[[Component_T, Context[Any]], Coro] | Callable[[Context[Any]], Coro] | None = None
225227

228+
translator: Translator[Any] | type[Translator[Any]] | None = getattr(callback, "__command_translator__", None)
229+
if translator and inspect.isclass(translator):
230+
translator = translator()
231+
232+
self._translator: Translator[Any] | None = translator
233+
226234
self._help: str = callback.__doc__ or ""
227235
self.__doc__ = self._help
228236

@@ -262,6 +270,13 @@ def _get_signature(self) -> None:
262270

263271
self._signature = help_sig
264272

273+
@property
274+
def translator(self) -> Translator[Any] | None:
275+
"""Property returning the :class:`.commands.Translator` associated with this command or ``None`` if one was not
276+
used.
277+
"""
278+
return self._translator
279+
265280
@property
266281
def parameters(self) -> MappingProxyType[str, inspect.Parameter]:
267282
"""Property returning a copy mapping of name to :class:`inspect.Parameter` pair, which are the parameters
@@ -1467,6 +1482,33 @@ def wrapper(
14671482
return wrapper
14681483

14691484

1485+
def translator(cls: Translator[Any] | type[Translator[Any]]) -> Any:
1486+
"""|deco|
1487+
1488+
Decorator which adds a :class:`.commands.Translator` to a :class:`.commands.Command`.
1489+
1490+
You can provide the class or instance of your implemented :class:`.commands.Translator` to this decorator.
1491+
1492+
See the :class:`.commands.Translator` documentation for more information on translators.
1493+
1494+
.. note::
1495+
1496+
You can only have one :class:`.commands.Translator` per command.
1497+
"""
1498+
1499+
def wrapper(func: Any) -> Any:
1500+
inst = cls() if inspect.isclass(cls) else cls
1501+
1502+
if isinstance(func, Command):
1503+
func._translator = inst
1504+
else:
1505+
func.__command_translator = inst
1506+
1507+
return func # type: ignore
1508+
1509+
return wrapper
1510+
1511+
14701512
def guard(predicate: Callable[..., bool] | Callable[..., CoroC]) -> Any:
14711513
"""A function which takes in a predicate as a either a standard function *or* coroutine function which should
14721514
return either ``True`` or ``False``, and adds it to your :class:`~.commands.Command` as a guard.

0 commit comments

Comments
 (0)