diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3715178260..94165f3f24 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,9 +24,9 @@ To push branches directly to the remote, you will have to name them like this: We have nox to help out with running pipelines locally and provides some helpful functionality. Nox is similar to tox, but uses a pure Python configuration instead of an -INI based configuration. Nox and tox are both tools for generating virtual +INI based configuration. Nox and tox are both tools for generating virtual environments and running commands in those environments. Examples of usage -include installing, configuring, and running flake8; running py.test, et +include installing, configuring, and running flake8; running py.test, et cetera. You can check all the available nox commands by running `nox -l`. diff --git a/examples/image_resources/image_resources.py b/examples/image_resources/image_resources.py index bebd279c58..8458a54fde 100644 --- a/examples/image_resources/image_resources.py +++ b/examples/image_resources/image_resources.py @@ -39,7 +39,7 @@ async def on_message(event: hikari.GuildMessageCreateEvent) -> None: # Do not respond to bots, webhooks, or messages without content or without a prefix. return - command, _, args = event.content[1:].partition(" ") + command, args = event.content[1:].split(" ", 1) if command == "image": await inspect_image(event, args.lstrip()) @@ -56,17 +56,10 @@ async def inspect_image(event: hikari.GuildMessageCreateEvent, what: str) -> Non elif what.casefold() in ("guild", "server", "here", "this"): await event.message.reply("Guild icon", attachment=event.guild.icon_url) - # Show the image for the given custom emoji: - elif custom_emoji_match := re.match(r"", what): - name, emoji_id = custom_emoji_match.group(1), hikari.Snowflake(custom_emoji_match.group(2)) - emoji = bot.cache.get_emoji(emoji_id) - await event.message.reply(f"Emoji {name}", attachment=emoji) - - # If any content exists, try treating it as a unicode emoji; only upload if it is actually valid: - elif what.strip(): - # If this is not a valid emoji, this will raise hikari.NotFoundError - emoji = hikari.UnicodeEmoji.from_emoji(what) - await event.message.reply("Unicode Emoji", attachment=emoji) + # Show the image for the given emoji if there is some content present. + elif what: + emoji = hikari.Emoji.parse(what) + await event.message.reply(emoji.name, attachment=emoji) # If nothing was given, we should just return the avatar of the person who ran the command: else: diff --git a/hikari/emojis.py b/hikari/emojis.py index 10cab458e1..04a70ed4d2 100644 --- a/hikari/emojis.py +++ b/hikari/emojis.py @@ -26,6 +26,7 @@ __all__: typing.List[str] = ["Emoji", "UnicodeEmoji", "CustomEmoji", "KnownCustomEmoji", "Emojiish"] import abc +import re import typing import unicodedata @@ -42,6 +43,7 @@ from hikari import users _TWEMOJI_PNG_BASE_URL: typing.Final[str] = "https://github.com/twitter/twemoji/raw/master/assets/72x72/" +_CUSTOM_EMOJI_REGEX: typing.Final[re.Pattern[str]] = re.compile(r"<(?P[^:]*):(?P[^:]*):(?P\d+)>") @attr.s(eq=True, hash=True, init=True, kw_only=True, slots=True, weakref_slot=False) @@ -79,6 +81,30 @@ def is_mentionable(self) -> bool: def mention(self) -> str: """Mention string to use to mention the emoji with.""" + @classmethod + def parse(cls, string: str, /) -> Emoji: + """Parse a given string into an emoji object. + + Parameters + ---------- + string : builtins.str + The emoji object to parse. + + Returns + ------- + Emoji + The parsed emoji object. This will be a `CustomEmoji` if a custom + emoji ID or mention, or a `UnicodeEmoji` otherwise. + + Raises + ------ + builtins.ValueError + If a mention is given that has an invalid format. + """ + if string.isdigit() or string.startswith("<") and string.endswith(">"): + return CustomEmoji.parse(string) + return UnicodeEmoji.parse(string) + @attr_extensions.with_copy @attr.s(hash=True, init=True, kw_only=False, slots=True, eq=False, weakref_slot=False) @@ -183,21 +209,37 @@ def unicode_escape(self) -> str: @classmethod @typing.final - def from_codepoints(cls, codepoint: int, *codepoints: int) -> UnicodeEmoji: + def parse_codepoints(cls, codepoint: int, *codepoints: int) -> UnicodeEmoji: """Create a unicode emoji from one or more UTF-32 codepoints.""" return cls(name="".join(map(chr, (codepoint, *codepoints)))) @classmethod @typing.final - def from_emoji(cls, emoji: str) -> UnicodeEmoji: - """Create a unicode emoji from a raw emoji.""" - return cls(name=emoji) + def parse_unicode_escape(cls, escape: str) -> UnicodeEmoji: + """Create a unicode emoji from a unicode escape string.""" + return cls(name=str(escape.encode("utf-8"), "unicode_escape")) @classmethod @typing.final - def from_unicode_escape(cls, escape: str) -> UnicodeEmoji: - """Create a unicode emoji from a unicode escape string.""" - return cls(name=str(escape.encode("utf-8"), "unicode_escape")) + def parse(cls, string: str, /) -> UnicodeEmoji: + """Parse a given string into a unicode emoji object. + + Parameters + ---------- + string : builtins.str + The emoji object to parse. + + Returns + ------- + UnicodeEmoji + The parsed UnicodeEmoji object. + """ + + # Ensure validity. + for i, codepoint in enumerate(string, start=1): + unicodedata.name(codepoint) + + return cls(name=string) @attr_extensions.with_copy @@ -226,11 +268,6 @@ class CustomEmoji(snowflakes.Unique, Emoji): https://github.com/discord/discord-api-docs/issues/1614#issuecomment-628548913 """ - app: traits.RESTAware = attr.ib( - repr=False, eq=False, hash=False, init=True, metadata={attr_extensions.SKIP_DEEP_COPY: True} - ) - """The client application that models may use for procedures.""" - id: snowflakes.Snowflake = attr.ib(eq=True, hash=True, repr=True) """The ID of this entity.""" @@ -273,6 +310,20 @@ def url(self) -> str: return routes.CDN_CUSTOM_EMOJI.compile(urls.CDN_URL, emoji_id=self.id, file_format=ext) + @classmethod + def parse(cls, string: str, /) -> CustomEmoji: + if string.isdigit(): + return CustomEmoji(id=snowflakes.Snowflake(string), name=None, is_animated=None) + + if emoji_match := _CUSTOM_EMOJI_REGEX.match(string): + return CustomEmoji( + id=snowflakes.Snowflake(emoji_match.group("id")), + name=emoji_match.group("name"), + is_animated=emoji_match.group("flags").lower() == "a", + ) + + raise ValueError("Expected an emoji ID or emoji mention") + @attr.s(eq=True, hash=True, init=True, kw_only=True, slots=True, weakref_slot=False) class KnownCustomEmoji(CustomEmoji): @@ -282,6 +333,11 @@ class KnownCustomEmoji(CustomEmoji): _are_ part of. As a result, it contains a lot more information with it. """ + app: traits.RESTAware = attr.ib( + repr=False, eq=False, hash=False, init=True, metadata={attr_extensions.SKIP_DEEP_COPY: True} + ) + """The client application that models may use for procedures.""" + guild_id: snowflakes.Snowflake = attr.ib(eq=False, hash=False, repr=False) """The ID of the guild this emoji belongs to.""" diff --git a/hikari/impl/entity_factory.py b/hikari/impl/entity_factory.py index e9dce0fb6c..066d54dd9d 100644 --- a/hikari/impl/entity_factory.py +++ b/hikari/impl/entity_factory.py @@ -905,7 +905,6 @@ def deserialize_unicode_emoji(self, payload: data_binding.JSONObject) -> emoji_m def deserialize_custom_emoji(self, payload: data_binding.JSONObject) -> emoji_models.CustomEmoji: return emoji_models.CustomEmoji( - app=self._app, id=snowflakes.Snowflake(payload["id"]), name=payload["name"], is_animated=payload.get("animated", False), diff --git a/scripts/test_twemoji_mapping.py b/scripts/test_twemoji_mapping.py index 98b3fb8846..3c04675b00 100644 --- a/scripts/test_twemoji_mapping.py +++ b/scripts/test_twemoji_mapping.py @@ -55,7 +55,7 @@ for i, emoji in enumerate(mapping, start=1): emoji_surrogates = emoji["surrogates"] name = emoji["primaryName"] - emoji = emojis.UnicodeEmoji.from_emoji(emoji_surrogates) + emoji = emojis.UnicodeEmoji.parse(emoji_surrogates) if emoji.filename in known_files: valid_emojis.append((emoji_surrogates, name)) diff --git a/tests/hikari/impl/test_entity_factory.py b/tests/hikari/impl/test_entity_factory.py index eca60e506e..9a81120b8b 100644 --- a/tests/hikari/impl/test_entity_factory.py +++ b/tests/hikari/impl/test_entity_factory.py @@ -1303,8 +1303,7 @@ def custom_emoji_payload(self): def test_deserialize_custom_emoji(self, entity_factory_impl, mock_app, custom_emoji_payload): emoji = entity_factory_impl.deserialize_custom_emoji(custom_emoji_payload) - assert emoji.app is mock_app - assert emoji.id == 691225175349395456 + assert emoji.id == snowflakes.Snowflake(691225175349395456) assert emoji.name == "test" assert emoji.is_animated is True assert isinstance(emoji, emoji_models.CustomEmoji) diff --git a/tests/hikari/impl/test_rest.py b/tests/hikari/impl/test_rest.py index 376ca870c6..395a4c399f 100644 --- a/tests/hikari/impl/test_rest.py +++ b/tests/hikari/impl/test_rest.py @@ -468,7 +468,7 @@ def test__generate_allowed_mentions(self, rest_client, function_input, expected_ @pytest.mark.parametrize( # noqa: PT014 - Duplicate test cases (false positive) ("emoji", "expected_return"), [ - (emojis.CustomEmoji(id=123, name="rooYay", app=object(), is_animated=False), "rooYay:123"), + (emojis.CustomEmoji(id=123, name="rooYay", is_animated=False), "rooYay:123"), ("👌", "👌"), ("\N{OK HAND SIGN}", "\N{OK HAND SIGN}"), ("<:rooYay:123>", "rooYay:123"), diff --git a/tests/hikari/test_emojis.py b/tests/hikari/test_emojis.py index 6c3cb7dd33..c3bcb5ab13 100644 --- a/tests/hikari/test_emojis.py +++ b/tests/hikari/test_emojis.py @@ -18,24 +18,74 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -import mock +import pytest from hikari import emojis +from hikari import snowflakes -def test_UnicodeEmoji_str_operator(): - mock_emoji = mock.Mock(emojis.UnicodeEmoji) - mock_emoji.name = "\N{OK HAND SIGN}" - assert emojis.UnicodeEmoji.__str__(mock_emoji) == "\N{OK HAND SIGN}" +class TestEmoji: + @pytest.mark.parametrize( + ("input", "output"), + [ + ("12345", emojis.CustomEmoji(id=snowflakes.Snowflake(12345), name=None, is_animated=None)), + ("<:foo:12345>", emojis.CustomEmoji(id=snowflakes.Snowflake(12345), name="foo", is_animated=False)), + ("", emojis.CustomEmoji(id=snowflakes.Snowflake(12345), name="foo", is_animated=False)), + ("", emojis.CustomEmoji(id=snowflakes.Snowflake(12345), name="foo", is_animated=True)), + ("\N{OK HAND SIGN}", emojis.UnicodeEmoji(name="\N{OK HAND SIGN}")), + ( + "\N{REGIONAL INDICATOR SYMBOL LETTER G}\N{REGIONAL INDICATOR SYMBOL LETTER B}", + emojis.UnicodeEmoji( + name="\N{REGIONAL INDICATOR SYMBOL LETTER G}\N{REGIONAL INDICATOR SYMBOL LETTER B}" + ), + ), + ], + ) + def test_parse(self, input, output): + assert emojis.Emoji.parse(input) == output -def test_CustomEmoji_str_operator(): - mock_emoji = mock.Mock(emojis.CustomEmoji, emojis.CustomEmoji) - mock_emoji.name = "peepoSad" - assert emojis.CustomEmoji.__str__(mock_emoji) == "peepoSad" +class TestUnicodeEmoji: + def test_str_operator(self): + assert emojis.UnicodeEmoji(name="\N{OK HAND SIGN}") == "\N{OK HAND SIGN}" + @pytest.mark.parametrize( + ("input", "output"), + [ + ("\N{OK HAND SIGN}", emojis.UnicodeEmoji(name="\N{OK HAND SIGN}")), + ( + "\N{REGIONAL INDICATOR SYMBOL LETTER G}\N{REGIONAL INDICATOR SYMBOL LETTER B}", + emojis.UnicodeEmoji( + name="\N{REGIONAL INDICATOR SYMBOL LETTER G}\N{REGIONAL INDICATOR SYMBOL LETTER B}" + ), + ), + ], + ) + def test_parse(self, input, output): + assert emojis.UnicodeEmoji.parse(input) == output -def test_CustomEmoji_str_operator_when_name_is_None(): - mock_emoji = mock.Mock(emojis.CustomEmoji, emojis.CustomEmoji, id=42069) - mock_emoji.name = None - assert emojis.CustomEmoji.__str__(mock_emoji) == "Unnamed emoji ID 42069" + +class TestCustomEmoji: + def test_str_operator_when_populated_name(self): + emoji = emojis.CustomEmoji(id=snowflakes.Snowflake(12345), name="peepoSad", is_animated=True) + assert str(emoji) == "peepoSad" + + def test_str_operator_when_name_is_None(self): + emoji = emojis.CustomEmoji(id=snowflakes.Snowflake(12345), name=None, is_animated=True) + assert str(emoji) == "Unnamed emoji ID 12345" + + @pytest.mark.parametrize( + ("input", "output"), + [ + ("12345", emojis.CustomEmoji(id=snowflakes.Snowflake(12345), name=None, is_animated=None)), + ("<:foo:12345>", emojis.CustomEmoji(id=snowflakes.Snowflake(12345), name="foo", is_animated=False)), + ("", emojis.CustomEmoji(id=snowflakes.Snowflake(12345), name="foo", is_animated=False)), + ("", emojis.CustomEmoji(id=snowflakes.Snowflake(12345), name="foo", is_animated=True)), + ], + ) + def test_parse(self, input, output): + assert emojis.CustomEmoji.parse(input) == output + + def test_parse_unhappy_path(self): + with pytest.raises(ValueError, match="Expected an emoji ID or emoji mention"): + emojis.CustomEmoji.parse("xxx")