From de941ababe9da898dd62d2b2a2d21aaecac6bd09 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Wed, 20 Apr 2022 06:49:28 -0400 Subject: [PATCH] Add Colour.from_str factory method This moves the command extension parsing code over to the main library since it can be potentially useful for others. --- discord/colour.py | 77 +++++++++++++++++++++++++++++++ discord/ext/commands/converter.py | 60 +++--------------------- tests/test_colour.py | 69 +++++++++++++++++++++++++++ 3 files changed, 153 insertions(+), 53 deletions(-) create mode 100644 tests/test_colour.py diff --git a/discord/colour.py b/discord/colour.py index 761405702..f6d88f868 100644 --- a/discord/colour.py +++ b/discord/colour.py @@ -25,6 +25,7 @@ from __future__ import annotations import colorsys import random +import re from typing import TYPE_CHECKING, Optional, Tuple, Union @@ -36,6 +37,44 @@ __all__ = ( 'Color', ) +RGB_REGEX = re.compile(r'rgb\s*\((?P[0-9.]+%?)\s*,\s*(?P[0-9.]+%?)\s*,\s*(?P[0-9.]+%?)\s*\)') + + +def parse_hex_number(argument: str) -> Colour: + arg = ''.join(i * 2 for i in argument) if len(argument) == 3 else argument + try: + value = int(arg, base=16) + if not (0 <= value <= 0xFFFFFF): + raise ValueError('hex number out of range for 24-bit colour') + except ValueError: + raise ValueError('invalid hex digit given') from None + else: + return Color(value=value) + + +def parse_rgb_number(number: str) -> int: + if number[-1] == '%': + value = float(number[:-1]) + if not (0 <= value <= 100): + raise ValueError('rgb percentage can only be between 0 to 100') + return round(255 * (value / 100)) + + value = int(number) + if not (0 <= value <= 255): + raise ValueError('rgb number can only be between 0 to 255') + return value + + +def parse_rgb(argument: str, *, regex: re.Pattern[str] = RGB_REGEX) -> Colour: + match = regex.match(argument) + if match is None: + raise ValueError('invalid rgb syntax found') + + red = parse_rgb_number(match.group('r')) + green = parse_rgb_number(match.group('g')) + blue = parse_rgb_number(match.group('b')) + return Color.from_rgb(red, green, blue) + class Colour: """Represents a Discord role colour. This class is similar @@ -130,6 +169,44 @@ class Colour: rgb = colorsys.hsv_to_rgb(h, s, v) return cls.from_rgb(*(int(x * 255) for x in rgb)) + @classmethod + def from_str(cls, value: str) -> Self: + """Constructs a :class:`Colour` from a string. + + The following formats are accepted: + + - ``0x`` + - ``#`` + - ``0x#`` + - ``rgb(, , )`` + + Like CSS, ```` can be either 0-255 or 0-100% and ```` can be + either a 6 digit hex number or a 3 digit hex shortcut (e.g. #fff). + + .. versionadded:: 2.0 + + Raises + ------- + ValueError + The string could not be converted into a colour. + """ + + if value[0] == '#': + return parse_hex_number(value[1:]) + + if value[0:2] == '0x': + rest = value[2:] + # Legacy backwards compatible syntax + if rest.startswith('#'): + return parse_hex_number(rest[1:]) + return parse_hex_number(rest) + + arg = value.lower() + if arg[0:3] == 'rgb': + return parse_rgb(arg) + + raise ValueError('unknown colour format given') + @classmethod def default(cls) -> Self: """A factory method that returns a :class:`Colour` with a value of ``0``.""" diff --git a/discord/ext/commands/converter.py b/discord/ext/commands/converter.py index 45dfea104..4a49c30dd 100644 --- a/discord/ext/commands/converter.py +++ b/discord/ext/commands/converter.py @@ -627,61 +627,15 @@ class ColourConverter(Converter[discord.Colour]): Added support for ``rgb`` function and 3-digit hex shortcuts """ - RGB_REGEX = re.compile(r'rgb\s*\((?P[0-9]{1,3}%?)\s*,\s*(?P[0-9]{1,3}%?)\s*,\s*(?P[0-9]{1,3}%?)\s*\)') - - def parse_hex_number(self, argument: str) -> discord.Colour: - arg = ''.join(i * 2 for i in argument) if len(argument) == 3 else argument + async def convert(self, ctx: Context[BotT], argument: str) -> discord.Colour: try: - value = int(arg, base=16) - if not (0 <= value <= 0xFFFFFF): - raise BadColourArgument(argument) + return discord.Colour.from_str(argument) except ValueError: - raise BadColourArgument(argument) - else: - return discord.Color(value=value) - - def parse_rgb_number(self, argument: str, number: str) -> int: - if number[-1] == '%': - value = int(number[:-1]) - if not (0 <= value <= 100): - raise BadColourArgument(argument) - return round(255 * (value / 100)) - - value = int(number) - if not (0 <= value <= 255): - raise BadColourArgument(argument) - return value - - def parse_rgb(self, argument: str, *, regex: re.Pattern[str] = RGB_REGEX) -> discord.Colour: - match = regex.match(argument) - if match is None: - raise BadColourArgument(argument) - - red = self.parse_rgb_number(argument, match.group('r')) - green = self.parse_rgb_number(argument, match.group('g')) - blue = self.parse_rgb_number(argument, match.group('b')) - return discord.Color.from_rgb(red, green, blue) - - async def convert(self, ctx: Context[BotT], argument: str) -> discord.Colour: - if argument[0] == '#': - return self.parse_hex_number(argument[1:]) - - if argument[0:2] == '0x': - rest = argument[2:] - # Legacy backwards compatible syntax - if rest.startswith('#'): - return self.parse_hex_number(rest[1:]) - return self.parse_hex_number(rest) - - arg = argument.lower() - if arg[0:3] == 'rgb': - return self.parse_rgb(arg) - - arg = arg.replace(' ', '_') - method = getattr(discord.Colour, arg, None) - if arg.startswith('from_') or method is None or not inspect.ismethod(method): - raise BadColourArgument(arg) - return method() + arg = argument.lower().replace(' ', '_') + method = getattr(discord.Colour, arg, None) + if arg.startswith('from_') or method is None or not inspect.ismethod(method): + raise BadColourArgument(arg) + return method() ColorConverter = ColourConverter diff --git a/tests/test_colour.py b/tests/test_colour.py new file mode 100644 index 000000000..bf0e59713 --- /dev/null +++ b/tests/test_colour.py @@ -0,0 +1,69 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +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. +""" + +from __future__ import annotations + +import discord +import pytest + + +@pytest.mark.parametrize( + ('value', 'expected'), + [ + ('0xFF1294', 0xFF1294), + ('0xff1294', 0xFF1294), + ('0xFFF', 0xFFFFFF), + ('0xfff', 0xFFFFFF), + ('#abcdef', 0xABCDEF), + ('#ABCDEF', 0xABCDEF), + ('#ABC', 0xAABBCC), + ('#abc', 0xAABBCC), + ('rgb(68,36,59)', 0x44243B), + ('rgb(26.7%, 14.1%, 23.1%)', 0x44243B), + ('rgb(20%, 24%, 56%)', 0x333D8F), + ('rgb(20%, 23.9%, 56.1%)', 0x333D8F), + ('rgb(51, 61, 143)', 0x333D8F), + ], +) +def test_from_str(value, expected): + assert discord.Colour.from_str(value) == discord.Colour(expected) + + +@pytest.mark.parametrize( + ('value'), + [ + 'not valid', + '0xYEAH', + '#YEAH', + '#yeah', + 'yellow', + 'rgb(-10, -20, -30)', + 'rgb(30, -1, 60)', + 'invalid(a, b, c)', + 'rgb(', + ], +) +def test_from_str_failures(value): + with pytest.raises(ValueError): + discord.Colour.from_str(value)