diff --git a/melisa/models/guild/channel.py b/melisa/models/guild/channel.py index 2a3b4b3..155a18f 100644 --- a/melisa/models/guild/channel.py +++ b/melisa/models/guild/channel.py @@ -21,8 +21,8 @@ from ..message.message import Message from ...exceptions import EmbedFieldError from ...models.message.embed import Embed from ...utils import Snowflake, Timestamp -from ...utils import APIModelBase -from ...utils.types import APINullable +from ...utils.api_model import APIModelBase +from ...utils.types import APINullable, UNDEFINED if TYPE_CHECKING: from .thread import ThreadMember, ThreadMetadata @@ -162,32 +162,32 @@ class Channel(APIModelBase): only included when part of the `resolved` data received on a slash command interaction """ - id: APINullable[Snowflake] = None - type: APINullable[int] = None - guild_id: APINullable[Snowflake] = None - position: APINullable[int] = None - permission_overwrites: APINullable[List] = None - name: APINullable[str] = None - topic: APINullable[str] = None - nsfw: APINullable[bool] = None - last_message_id: APINullable[Snowflake] = None - bitrate: APINullable[int] = None - user_limit: APINullable[int] = None - rate_limit_per_user: APINullable[int] = None - recipients: APINullable[List] = None - icon: APINullable[str] = None - owner_id: APINullable[Snowflake] = None - application_id: APINullable[Snowflake] = None - parent_id: APINullable[Snowflake] = None - last_pin_timestamp: APINullable[Timestamp] = None - rtc_region: APINullable[str] = None - video_quality_mode: APINullable[int] = None - message_count: APINullable[int] = None - member_count: APINullable[int] = None - thread_metadata: APINullable[ThreadMetadata] = None - member: APINullable[List] = None - default_auto_archive_duration: APINullable[int] = None - permissions: APINullable[str] = None + id: APINullable[Snowflake] = UNDEFINED + type: APINullable[int] = UNDEFINED + guild_id: APINullable[Snowflake] = UNDEFINED + position: APINullable[int] = UNDEFINED + permission_overwrites: APINullable[List] = UNDEFINED + name: APINullable[str] = UNDEFINED + topic: APINullable[str] = UNDEFINED + nsfw: APINullable[bool] = UNDEFINED + last_message_id: APINullable[Snowflake] = UNDEFINED + bitrate: APINullable[int] = UNDEFINED + user_limit: APINullable[int] = UNDEFINED + rate_limit_per_user: APINullable[int] = UNDEFINED + recipients: APINullable[List] = UNDEFINED + icon: APINullable[str] = UNDEFINED + owner_id: APINullable[Snowflake] = UNDEFINED + application_id: APINullable[Snowflake] = UNDEFINED + parent_id: APINullable[Snowflake] = UNDEFINED + last_pin_timestamp: APINullable[Timestamp] = UNDEFINED + rtc_region: APINullable[str] = UNDEFINED + video_quality_mode: APINullable[int] = UNDEFINED + message_count: APINullable[int] = UNDEFINED + member_count: APINullable[int] = UNDEFINED + thread_metadata: APINullable[ThreadMetadata] = UNDEFINED + member: APINullable[List] = UNDEFINED + default_auto_archive_duration: APINullable[int] = UNDEFINED + permissions: APINullable[str] = UNDEFINED @property def mention(self): @@ -846,7 +846,7 @@ class ThreadsList(APIModelBase): threads: List[Thread] members: List[ThreadMember] - has_more: APINullable[bool] = None + has_more: APINullable[bool] = UNDEFINED # noinspection PyTypeChecker diff --git a/melisa/models/guild/guild.py b/melisa/models/guild/guild.py index 99b77b8..6e0da42 100644 --- a/melisa/models/guild/guild.py +++ b/melisa/models/guild/guild.py @@ -9,8 +9,8 @@ from typing import List, Any, Optional, overload from .channel import Channel, ChannelType, channel_types_for_converting, ThreadsList from ...utils import Snowflake, Timestamp -from ...utils import APIModelBase -from ...utils.types import APINullable +from ...utils.api_model import APIModelBase +from ...utils.types import APINullable, UNDEFINED class DefaultMessageNotificationLevel(IntEnum): @@ -292,63 +292,63 @@ class Guild(APIModelBase): The scheduled events in the guild """ - id: APINullable[Snowflake] = None - name: APINullable[str] = None - icon: APINullable[str] = None - icon_hash: APINullable[str] = None - splash: APINullable[str] = None - discovery_splash: APINullable[str] = None - owner: APINullable[bool] = None - owner_id: APINullable[Snowflake] = None - permissions: APINullable[str] = None - region: APINullable[str] = None - afk_channel_id: APINullable[Snowflake] = None - afk_timeout: APINullable[int] = None - widget_enabled: APINullable[bool] = None - widget_channel_id: APINullable[Snowflake] = None - verification_level: APINullable[int] = None - default_message_notifications: APINullable[int] = None - explicit_content_filter: APINullable[int] = None - features: APINullable[List[str]] = None - roles: APINullable[List] = None - emojis: APINullable[List] = None + id: APINullable[Snowflake] = UNDEFINED + name: APINullable[str] = UNDEFINED + icon: APINullable[str] = UNDEFINED + icon_hash: APINullable[str] = UNDEFINED + splash: APINullable[str] = UNDEFINED + discovery_splash: APINullable[str] = UNDEFINED + owner: APINullable[bool] = UNDEFINED + owner_id: APINullable[Snowflake] = UNDEFINED + permissions: APINullable[str] = UNDEFINED + region: APINullable[str] = UNDEFINED + afk_channel_id: APINullable[Snowflake] = UNDEFINED + afk_timeout: APINullable[int] = UNDEFINED + widget_enabled: APINullable[bool] = UNDEFINED + widget_channel_id: APINullable[Snowflake] = UNDEFINED + verification_level: APINullable[int] = UNDEFINED + default_message_notifications: APINullable[int] = UNDEFINED + explicit_content_filter: APINullable[int] = UNDEFINED + features: APINullable[List[str]] = UNDEFINED + roles: APINullable[List] = UNDEFINED + emojis: APINullable[List] = UNDEFINED # TODO: Make a structures of emoji and role - mfa_level: APINullable[int] = None - application_id: APINullable[Snowflake] = None - system_channel_id: APINullable[Snowflake] = None - system_channel_flags: APINullable[int] = None - rules_channel_id: APINullable[Snowflake] = None - joined_at: APINullable[Timestamp] = None + mfa_level: APINullable[int] = UNDEFINED + application_id: APINullable[Snowflake] = UNDEFINED + system_channel_id: APINullable[Snowflake] = UNDEFINED + system_channel_flags: APINullable[int] = UNDEFINED + rules_channel_id: APINullable[Snowflake] = UNDEFINED + joined_at: APINullable[Timestamp] = UNDEFINED # TODO: Deal with joined_at - large: APINullable[bool] = None - unavailable: APINullable[bool] = None - member_count: APINullable[int] = None - voice_states: APINullable[List] = None - members: APINullable[List] = None - threads: APINullable[List] = None - presences: APINullable[List] = None + large: APINullable[bool] = UNDEFINED + unavailable: APINullable[bool] = UNDEFINED + member_count: APINullable[int] = UNDEFINED + voice_states: APINullable[List] = UNDEFINED + members: APINullable[List] = UNDEFINED + threads: APINullable[List] = UNDEFINED + presences: APINullable[List] = UNDEFINED # TODO: Make a structure for voice_states, members, channels, threads, presences(?) - max_presences: APINullable[int] = None - max_members: APINullable[int] = None - vanity_url_code: APINullable[str] = None - description: APINullable[str] = None - banner: APINullable[str] = None - premium_tier: APINullable[str] = None - premium_subscription_count: APINullable[int] = None - preferred_locale: APINullable[str] = None - public_updates_channel_id: APINullable[Snowflake] = None - max_video_channel_users: APINullable[int] = None - approximate_member_count: APINullable[int] = None - approximate_presence_count: APINullable[int] = None - nsfw_level: APINullable[int] = None - premium_progress_bar_enabled: APINullable[bool] = None - stage_instances: APINullable[List] = None - stickers: APINullable[List] = None - welcome_screen: APINullable = None - guild_scheduled_events: APINullable[List] = None + max_presences: APINullable[int] = UNDEFINED + max_members: APINullable[int] = UNDEFINED + vanity_url_code: APINullable[str] = UNDEFINED + description: APINullable[str] = UNDEFINED + banner: APINullable[str] = UNDEFINED + premium_tier: APINullable[str] = UNDEFINED + premium_subscription_count: APINullable[int] = UNDEFINED + preferred_locale: APINullable[str] = UNDEFINED + public_updates_channel_id: APINullable[Snowflake] = UNDEFINED + max_video_channel_users: APINullable[int] = UNDEFINED + approximate_member_count: APINullable[int] = UNDEFINED + approximate_presence_count: APINullable[int] = UNDEFINED + nsfw_level: APINullable[int] = UNDEFINED + premium_progress_bar_enabled: APINullable[bool] = UNDEFINED + stage_instances: APINullable[List] = UNDEFINED + stickers: APINullable[List] = UNDEFINED + welcome_screen: APINullable = UNDEFINED + guild_scheduled_events: APINullable[List] = UNDEFINED # TODO: Make a structure for welcome_screen, stage_instances, # stickers and guild_scheduled_events diff --git a/melisa/models/guild/thread.py b/melisa/models/guild/thread.py index 2f04dba..beb4633 100644 --- a/melisa/models/guild/thread.py +++ b/melisa/models/guild/thread.py @@ -6,7 +6,7 @@ from __future__ import annotations from dataclasses import dataclass from ...utils.api_model import APIModelBase -from ...utils.types import APINullable +from ...utils.types import APINullable, UNDEFINED from ...utils.snowflake import Snowflake from ...utils.timestamp import Timestamp @@ -40,8 +40,8 @@ class ThreadMetadata(APIModelBase): auto_archive_duration: int archive_timestamp: Timestamp locked: bool - invitable: APINullable[bool] = None - create_timestamp: APINullable[Timestamp] = None + invitable: APINullable[bool] = UNDEFINED + create_timestamp: APINullable[Timestamp] = UNDEFINED @dataclass(repr=False) @@ -62,5 +62,5 @@ class ThreadMember(APIModelBase): join_timestamp: Timestamp flags: int - id: APINullable[Snowflake] = None - user_id: APINullable[Snowflake] = None + id: APINullable[Snowflake] = UNDEFINED + user_id: APINullable[Snowflake] = UNDEFINED diff --git a/melisa/models/guild/webhook.py b/melisa/models/guild/webhook.py index 411700c..968b4ed 100644 --- a/melisa/models/guild/webhook.py +++ b/melisa/models/guild/webhook.py @@ -8,8 +8,8 @@ from enum import IntEnum from typing import TYPE_CHECKING, Optional from ...utils import Snowflake -from ...utils import APIModelBase -from ...utils.types import APINullable +from ...utils.api_model import APIModelBase +from ...utils.types import APINullable, UNDEFINED if TYPE_CHECKING: from ..user.user import User @@ -73,18 +73,18 @@ class Webhook(APIModelBase): The url used for executing the webhook (returned by the webhooks OAuth2 flow) """ - id: APINullable[Snowflake] = None - type: APINullable[int] = None - guild_id: APINullable[Snowflake] = None - channel_id: APINullable[Snowflake] = None - user: APINullable[User] = None - name: APINullable[str] = None - avatar: APINullable[str] = None - token: APINullable[str] = None - application_id: APINullable[Snowflake] = None - source_guild: APINullable[Guild] = None - source_channel: APINullable[Channel] = None - url: APINullable[str] = None + id: APINullable[Snowflake] = UNDEFINED + type: APINullable[int] = UNDEFINED + guild_id: APINullable[Snowflake] = UNDEFINED + channel_id: APINullable[Snowflake] = UNDEFINED + user: APINullable[User] = UNDEFINED + name: APINullable[str] = UNDEFINED + avatar: APINullable[str] = UNDEFINED + token: APINullable[str] = UNDEFINED + application_id: APINullable[Snowflake] = UNDEFINED + source_guild: APINullable[Guild] = UNDEFINED + source_channel: APINullable[Channel] = UNDEFINED + url: APINullable[str] = UNDEFINED async def delete( self, *, webhook_id: Optional[Snowflake] = None, reason: Optional[str] = None diff --git a/melisa/models/message/__init__.py b/melisa/models/message/__init__.py index 3a11a3f..f0161a9 100644 --- a/melisa/models/message/__init__.py +++ b/melisa/models/message/__init__.py @@ -3,3 +3,4 @@ from .message import * from .embed import * +from .colors import * diff --git a/melisa/models/message/colors.py b/melisa/models/message/colors.py new file mode 100644 index 0000000..0c4d9b5 --- /dev/null +++ b/melisa/models/message/colors.py @@ -0,0 +1,156 @@ +# Copyright MelisaDev 2022 - Present +# Full MIT License can be found in `LICENSE.txt` at the project root. + +from __future__ import annotations + +import random +import string +import typing + + +CT = typing.TypeVar("CT", bound="Color") + + +class Color: + """Represents a Discord colour. This class is similar + to a (red, green, blue) :class:`tuple`. + + .. container:: operations + .. describe:: x == y + Checks if two colours are equal. + .. describe:: x != y + Checks if two colours are not equal. + .. describe:: hash(x) + Return the colour's hash. + .. describe:: str(x) + Returns the hex format for the colour. + .. describe:: int(x) + Returns the raw colour value. + + Attributes + ---------- + value: :class:`int` + The raw integer colour value. + """ + + __slots__ = ("value",) + + def __init__(self, value: int): + if not isinstance(value, int): + raise TypeError( + f"Expected int parameter, received {value.__class__.__name__} instead." + ) + + self.value: int = value + + def _get_byte(self, byte: int) -> int: + return (self.value >> (8 * byte)) & 0xFF + + def __eq__(self, other: typing.Any) -> bool: + return isinstance(other, Color) and self.value == other.value + + def __ne__(self, other: typing.Any) -> bool: + return not self.__eq__(other) + + def __str__(self) -> str: + return f"#{self.value:0>6x}" + + def __int__(self) -> int: + return self.value + + def __repr__(self) -> str: + return f"" + + def __hash__(self) -> int: + return hash(self.value) + + @property + def r(self) -> int: + """:class:`int`: Returns the red component of the colour.""" + return self._get_byte(2) + + @property + def g(self) -> int: + """:class:`int`: Returns the green component of the colour.""" + return self._get_byte(1) + + @property + def b(self) -> int: + """:class:`int`: Returns the blue component of the colour.""" + return self._get_byte(0) + + def to_rgb(self) -> typing.Tuple[int, int, int]: + """ + Tuple[:class:`int`, :class:`int`, :class:`int`]: + Returns an (r, g, b) tuple representing the colour.""" + return (self.r, self.g, self.b) + + @classmethod + def from_rgb(cls: typing.Type[CT], r: int, g: int, b: int) -> CT: + """Constructs a :class:`Colour` from an RGB tuple.""" + return cls((r << 16) + (g << 8) + b) + + @classmethod + def from_hex_code(cls, hex_code: str, /) -> Color: + """Convert the given hexadecimal color code to a `Color`. + + The inputs may be of the following format (case insensitive): + `1a2`, `#1a2`, `0x1a2` (for web-safe colors), or + `1a2b3c`, `#1a2b3c`, `0x1a2b3c` (for regular 3-byte color-codes). + + Parameters + ---------- + hex_code: :class:`str` + A hexadecimal color code to parse. This may optionally start with + a case insensitive `0x` or `#`. + + Returns + ------- + Color + A corresponding Color object. + + Raises + ------ + :class:`ValueError` + If ``hex_code`` is not a hexadecimal or is a invalid length. + """ + if hex_code.startswith("#"): + hex_code = hex_code[1:] + elif hex_code.startswith(("0x", "0X")): + hex_code = hex_code[2:] + + if not all(c in string.hexdigits for c in hex_code): + raise ValueError("Color code must be hexadecimal") + + if len(hex_code) == 3: + r, g, b = (c << 4 | c for c in (int(c, 16) for c in hex_code)) + return cls.from_rgb(r, g, b) + + if len(hex_code) == 6: + return cls.from_rgb( + int(hex_code[:2], 16), int(hex_code[2:4], 16), int(hex_code[4:6], 16) + ) + + raise ValueError("Color code is invalid length. Must be 3 or 6 digits") + + @classmethod + def default(cls: typing.Type[CT]) -> CT: + """A factory method that returns a :class:`Colour` with a value of ``0``.""" + return cls(0) + + @classmethod + def random( + cls: typing.Type[CT], + *, + seed: typing.Optional[typing.Union[int, str, float, bytes, bytearray]] = None, + ) -> CT: + """A factory method that returns a :class:`Colour` with a random hue. + + Parameters + ---------- + seed: Optional[Union[:class:`int`, :class:`str`, + :class:`float`, :class:`bytes`, :class:`bytearray`]] + The seed to initialize the RNG with. If ``None`` is passed the default RNG is used. + """ + rand = random if seed is None else random.Random(seed) + return cls.from_hsv(rand.random(), 1, 1) diff --git a/melisa/models/message/embed.py b/melisa/models/message/embed.py index 530b801..1ab3476 100644 --- a/melisa/models/message/embed.py +++ b/melisa/models/message/embed.py @@ -9,9 +9,10 @@ from enum import Enum from typing import List, Union, Optional +from .colors import Color from melisa.exceptions import EmbedFieldError -from melisa.utils.types import UNDEFINED, UndefinedOr -from melisa.utils.api_model import APIModelBase, APINullable +from ...utils.api_model import APIModelBase +from ...utils.types import APINullable, UNDEFINED from melisa.utils.timestamp import Timestamp @@ -47,7 +48,7 @@ class EmbedType(Enum): @dataclass(repr=False) -class EmbedThumbnail(APIModelBase): +class EmbedThumbnail: """Representation of the Embed Thumbnail Attributes @@ -63,13 +64,13 @@ class EmbedThumbnail(APIModelBase): """ url: str - proxy_url: APINullable[str] = None - height: APINullable[int] = None - width: APINullable[int] = None + proxy_url: APINullable[str] = UNDEFINED + height: APINullable[int] = UNDEFINED + width: APINullable[int] = UNDEFINED @dataclass(repr=False) -class EmbedVideo(APIModelBase): +class EmbedVideo: """Representation of the Embed Video Attributes @@ -85,13 +86,13 @@ class EmbedVideo(APIModelBase): """ url: str - proxy_url: APINullable[str] = None - height: APINullable[int] = None - width: APINullable[int] = None + proxy_url: APINullable[str] = UNDEFINED + height: APINullable[int] = UNDEFINED + width: APINullable[int] = UNDEFINED @dataclass(repr=False) -class EmbedImage(APIModelBase): +class EmbedImage: """Representation of the Embed Image Attributes @@ -107,13 +108,13 @@ class EmbedImage(APIModelBase): """ url: str - proxy_url: APINullable[str] = None - height: APINullable[int] = None - width: APINullable[int] = None + proxy_url: APINullable[str] = UNDEFINED + height: APINullable[int] = UNDEFINED + width: APINullable[int] = UNDEFINED @dataclass(repr=False) -class EmbedProvider(APIModelBase): +class EmbedProvider: """Representation of the Embed Provider Attributes @@ -124,12 +125,12 @@ class EmbedProvider(APIModelBase): Url of provider """ - name: APINullable[str] = None - url: APINullable[str] = None + name: APINullable[str] = UNDEFINED + url: APINullable[str] = UNDEFINED @dataclass(repr=False) -class EmbedAuthor(APIModelBase): +class EmbedAuthor: """Representation of the Embed Author Attributes @@ -145,13 +146,13 @@ class EmbedAuthor(APIModelBase): """ name: str - url: APINullable[str] = None - icon_url: APINullable[str] = None - proxy_icon_url: APINullable[str] = None + url: APINullable[str] = UNDEFINED + icon_url: APINullable[str] = UNDEFINED + proxy_icon_url: APINullable[str] = UNDEFINED @dataclass(repr=False) -class EmbedFooter(APIModelBase): +class EmbedFooter: """Representation of the Embed Footer Attributes @@ -165,12 +166,12 @@ class EmbedFooter(APIModelBase): """ text: str - icon_url: APINullable[str] = None - proxy_icon_url: APINullable[str] = None + icon_url: APINullable[str] = UNDEFINED + proxy_icon_url: APINullable[str] = UNDEFINED @dataclass(repr=False) -class EmbedField(APIModelBase): +class EmbedField: """Representation of the Embed Field Attributes @@ -185,7 +186,7 @@ class EmbedField(APIModelBase): name: str value: str - inline: APINullable[bool] = False + inline: Optional[bool] = False @dataclass(repr=False) @@ -202,7 +203,12 @@ class Embed(APIModelBase): description: Optional[:class:`str`] Description of embed color: Optional[:class:`int`] - Color code of the embed + Color code of the embed. + If you really want to do something with a color, + feel free to convert it to the ``Color``: :: + + color = Color(embed.color) + fields: Optional[List[:class:`~melisa.models.message.embed.EmbedField`]] Fields information. footer: Optional[:class:`~melisa.models.message.embed.EmbedFooter`] @@ -221,19 +227,19 @@ class Embed(APIModelBase): Video information. """ - title: APINullable[str] = None - type: APINullable[EmbedType] = None - description: APINullable[str] = None - url: APINullable[str] = None - timestamp: APINullable[Timestamp] = None - color: APINullable[int] = None - footer: APINullable[EmbedFooter] = None - image: APINullable[EmbedImage] = None - thumbnail: APINullable[EmbedThumbnail] = None - video: APINullable[EmbedVideo] = None - provider: APINullable[EmbedProvider] = None - author: APINullable[EmbedAuthor] = None - fields: APINullable[List[EmbedField]] = None + title: APINullable[str] = UNDEFINED + type: APINullable[EmbedType] = UNDEFINED + description: APINullable[str] = UNDEFINED + url: APINullable[str] = UNDEFINED + timestamp: APINullable[Timestamp] = UNDEFINED + color: APINullable[Color] = UNDEFINED + footer: APINullable[EmbedFooter] = UNDEFINED + image: APINullable[EmbedImage] = UNDEFINED + thumbnail: APINullable[EmbedThumbnail] = UNDEFINED + video: APINullable[EmbedVideo] = UNDEFINED + provider: APINullable[EmbedProvider] = UNDEFINED + author: APINullable[EmbedAuthor] = UNDEFINED + fields: APINullable[List[EmbedField]] = UNDEFINED def __post_init__(self): if self.title and len(self.title) > 256: @@ -251,6 +257,26 @@ class Embed(APIModelBase): if self.fields and len(self.fields) > 25: raise EmbedFieldError("""You can't set more than 25 embed fields!""") + def set_color(self, color: Union[int, Color]) -> Embed: + """Sets color in the supported by discord format. + + Parameters + ---------- + color: Union[:class:`~melisa.models.message.color.Color`, :class:`int`] + The datetime to set the timestamp to. + + Returns + ------- + :class:`~melisa.models.message.embed.Embed` + The new embed object. + """ + if isinstance(color, Color): + self.color = color.value + elif isinstance(color, int): + self.color = Color(value=color).value + + return self + def set_timestamp(self, time: Union[Timestamp, datetime]) -> Embed: """Sets timestamp in the supported by discord format. @@ -261,7 +287,7 @@ class Embed(APIModelBase): Returns ------- - :class:`~,e;osa.models.message.embed.Embed` + :class:`~melisa.models.message.embed.Embed` The new embed object. """ self.timestamp = time.isoformat() @@ -272,9 +298,9 @@ class Embed(APIModelBase): self, name: str, *, - url: Optional[str] = None, - icon_url: Optional[str] = None, - proxy_icon_url: Optional[str] = None, + url: Optional[str] = UNDEFINED, + icon_url: Optional[str] = UNDEFINED, + proxy_icon_url: Optional[str] = UNDEFINED, ) -> Embed: """Set the author for the embed. @@ -303,7 +329,7 @@ class Embed(APIModelBase): return self - def set_image(self, url: str, *, proxy_url: Optional[str] = None) -> Embed: + def set_image(self, url: str, *, proxy_url: APINullable[str] = UNDEFINED) -> Embed: """Set the image for the embed. Parameters @@ -322,7 +348,7 @@ class Embed(APIModelBase): return self - def set_thumbnail(self, url: str, *, proxy_url: Optional[str] = None) -> Embed: + def set_thumbnail(self, url: str, *, proxy_url: APINullable[str] = UNDEFINED) -> Embed: """Set the thumbnail for the embed. Parameters @@ -345,8 +371,8 @@ class Embed(APIModelBase): self, text: str, *, - icon_url: Optional[str] = None, - proxy_icon_url: Optional[str] = None, + icon_url: APINullable[str] = UNDEFINED, + proxy_icon_url: APINullable[str] = UNDEFINED, ) -> Embed: """ Sets the embed footer. @@ -392,7 +418,7 @@ class Embed(APIModelBase): This embed. """ - if self.fields is None: + if self.fields is UNDEFINED: self.fields = [] self.fields.append(EmbedField(name=name, value=value, inline=inline)) @@ -403,9 +429,9 @@ class Embed(APIModelBase): self, index: int, *, - name: UndefinedOr[str] = UNDEFINED, - value: UndefinedOr[str] = UNDEFINED, - inline: UndefinedOr[bool] = UNDEFINED, + name: APINullable[str] = UNDEFINED, + value: APINullable[str] = UNDEFINED, + inline: APINullable[bool] = UNDEFINED, ) -> Embed: """Edit an existing field on this embed. @@ -413,11 +439,11 @@ class Embed(APIModelBase): ---------- index: :class:`int` The index of the field to edit. - name: UndefinedOr[:class:`str`] + name: Optional[:class:`str`] The name of the field. - value: UndefinedOr[:class:`str`] + value: Optional[:class:`str`] The value of the field. - inline: UndefinedOr[:class:`bool`] + inline: Optional[:class:`bool`] Whether the field should be displayed inline. Returns @@ -468,7 +494,7 @@ class Embed(APIModelBase): del self.fields[index] if not self.fields: - self.fields = None + self.fields = UNDEFINED return self diff --git a/melisa/models/message/message.py b/melisa/models/message/message.py index 22717cb..11b63c1 100644 --- a/melisa/models/message/message.py +++ b/melisa/models/message/message.py @@ -9,7 +9,7 @@ from typing import List, TYPE_CHECKING, Optional, Dict from ...utils import Snowflake, Timestamp from ...utils import APIModelBase -from ...utils.types import APINullable +from ...utils.types import APINullable, UNDEFINED if TYPE_CHECKING: from ..guild.channel import Thread @@ -170,36 +170,36 @@ class Message(APIModelBase): Deprecated the stickers sent with the message """ - id: APINullable[Snowflake] = None - channel_id: APINullable[Snowflake] = None - guild_id: APINullable[Snowflake] = None - author: APINullable[Dict] = None - member: APINullable[Dict] = None - content: APINullable[str] = None - timestamp: APINullable[Timestamp] = None - edited_timestamp: APINullable[Timestamp] = None - tts: APINullable[bool] = None - mention_everyone: APINullable[bool] = None - mentions: APINullable[List] = None - mention_roles: APINullable[List] = None - mention_channels: APINullable[List] = None - attachments: APINullable[List] = None - embeds: APINullable[List] = None - reactions: APINullable[List] = None - nonce: APINullable[int] or APINullable[str] = None - pinned: APINullable[bool] = None - webhook_id: APINullable[Snowflake] = None - type: APINullable[int] = None - activity: APINullable[Dict] = None - application: APINullable[Dict] = None - application_id: APINullable[Snowflake] = None - message_reference: APINullable[Dict] = None - flags: APINullable[int] = None - interaction: APINullable[Dict] = None - thread: APINullable[Thread] = None - components: APINullable[List] = None - sticker_items: APINullable[List] = None - stickers: APINullable[List] = None + id: APINullable[Snowflake] = UNDEFINED + channel_id: APINullable[Snowflake] = UNDEFINED + guild_id: APINullable[Snowflake] = UNDEFINED + author: APINullable[Dict] = UNDEFINED + member: APINullable[Dict] = UNDEFINED + content: APINullable[str] = UNDEFINED + timestamp: APINullable[Timestamp] = UNDEFINED + edited_timestamp: APINullable[Timestamp] = UNDEFINED + tts: APINullable[bool] = UNDEFINED + mention_everyone: APINullable[bool] = UNDEFINED + mentions: APINullable[List] = UNDEFINED + mention_roles: APINullable[List] = UNDEFINED + mention_channels: APINullable[List] = UNDEFINED + attachments: APINullable[List] = UNDEFINED + embeds: APINullable[List] = UNDEFINED + reactions: APINullable[List] = UNDEFINED + nonce: APINullable[int] or APINullable[str] = UNDEFINED + pinned: APINullable[bool] = UNDEFINED + webhook_id: APINullable[Snowflake] = UNDEFINED + type: APINullable[int] = UNDEFINED + activity: APINullable[Dict] = UNDEFINED + application: APINullable[Dict] = UNDEFINED + application_id: APINullable[Snowflake] = UNDEFINED + message_reference: APINullable[Dict] = UNDEFINED + flags: APINullable[int] = UNDEFINED + interaction: APINullable[Dict] = UNDEFINED + thread: APINullable[Thread] = UNDEFINED + components: APINullable[List] = UNDEFINED + sticker_items: APINullable[List] = UNDEFINED + stickers: APINullable[List] = UNDEFINED async def pin(self, *, reason: Optional[str] = None): """|coro| diff --git a/melisa/models/user/presence.py b/melisa/models/user/presence.py index af588c8..870cc72 100644 --- a/melisa/models/user/presence.py +++ b/melisa/models/user/presence.py @@ -7,7 +7,7 @@ from typing import Optional, Tuple, List, Literal from ...utils import Snowflake from ...utils import APIModelBase -from ...utils.types import APINullable +from ...utils.types import APINullable, UNDEFINED class BasePresence: @@ -223,19 +223,19 @@ class Activity(BasePresence, APIModelBase): name: str type: ActivityType - created_at: APINullable[int] = None - url: APINullable[str] = None - timestamps: APINullable[ActivityTimestamp] = None - application_id: APINullable[Snowflake] = None - details: APINullable[str] = None - state: APINullable[str] = None - emoji: APINullable[ActivityEmoji] = None - party: APINullable[ActivityParty] = None - assets: APINullable[ActivityAssets] = None - secrets: APINullable[ActivitySecrets] = None - instance: APINullable[bool] = None - flags: APINullable[ActivityFlags] = None - buttons: APINullable[List[ActivityButton]] = None + created_at: APINullable[int] = UNDEFINED + url: APINullable[str] = UNDEFINED + timestamps: APINullable[ActivityTimestamp] = UNDEFINED + application_id: APINullable[Snowflake] = UNDEFINED + details: APINullable[str] = UNDEFINED + state: APINullable[str] = UNDEFINED + emoji: APINullable[ActivityEmoji] = UNDEFINED + party: APINullable[ActivityParty] = UNDEFINED + assets: APINullable[ActivityAssets] = UNDEFINED + secrets: APINullable[ActivitySecrets] = UNDEFINED + instance: APINullable[bool] = UNDEFINED + flags: APINullable[ActivityFlags] = UNDEFINED + buttons: APINullable[List[ActivityButton]] = UNDEFINED class StatusType(Enum): diff --git a/melisa/models/user/user.py b/melisa/models/user/user.py index 5d2ae40..3453448 100644 --- a/melisa/models/user/user.py +++ b/melisa/models/user/user.py @@ -8,7 +8,7 @@ from dataclasses import dataclass from typing import Optional from ...utils.api_model import APIModelBase -from ...utils.types import APINullable +from ...utils.types import APINullable, UNDEFINED from ...utils.snowflake import Snowflake @@ -149,20 +149,20 @@ class User(APIModelBase): The user their premium type in a usable enum. """ - id: APINullable[Snowflake] = None - username: APINullable[str] = None - discriminator: APINullable[str] = None - avatar: APINullable[str] = None - bot: APINullable[bool] = None - system: APINullable[bool] = None - mfa_enabled: APINullable[bool] = None - banner: APINullable[str] = None - accent_color: APINullable[int] = None - local: APINullable[str] = None - verified: APINullable[bool] = None - email: APINullable[str] = None - premium_type: APINullable[int] = None - public_flags: APINullable[int] = None + id: APINullable[Snowflake] = UNDEFINED + username: APINullable[str] = UNDEFINED + discriminator: APINullable[str] = UNDEFINED + avatar: APINullable[str] = UNDEFINED + bot: APINullable[bool] = UNDEFINED + system: APINullable[bool] = UNDEFINED + mfa_enabled: APINullable[bool] = UNDEFINED + banner: APINullable[str] = UNDEFINED + accent_color: APINullable[int] = UNDEFINED + local: APINullable[str] = UNDEFINED + verified: APINullable[bool] = UNDEFINED + email: APINullable[str] = UNDEFINED + premium_type: APINullable[int] = UNDEFINED + public_flags: APINullable[int] = UNDEFINED @property def premium(self) -> Optional[PremiumTypes]: diff --git a/melisa/utils/api_model.py b/melisa/utils/api_model.py index 8e75c60..1edd5dd 100644 --- a/melisa/utils/api_model.py +++ b/melisa/utils/api_model.py @@ -6,7 +6,6 @@ from __future__ import annotations import copy -import datetime from dataclasses import _is_dataclass_instance, fields from enum import Enum, EnumMeta from inspect import getfullargspec @@ -19,45 +18,58 @@ from typing import ( Any, get_origin, Tuple, - get_args, + get_args, Optional, ) from typing_extensions import get_type_hints -from melisa.utils.types import APINullable, TypeCache +from melisa.utils.types import UndefinedType, TypeCache, UNDEFINED T = TypeVar("T") -def to_dict_without_none(model): +def _asdict_ignore_none(obj: Generic[T]) -> Union[Tuple, Dict, T]: """ - Converts discord model or other object to dict. + Returns a dict from a dataclass that ignores + all values that are None + Modification of _asdict_inner from dataclasses + Parameters + ---------- + obj: Generic[T] + The object to convert + Returns + ------- + A dict without None values """ - if _is_dataclass_instance(model): - result = [] - for field in fields(model): - value = to_dict_without_none(getattr(model, field.name)) + if _is_dataclass_instance(obj): + result = [] + for f in fields(obj): + value = _asdict_ignore_none(getattr(obj, f.name)) if isinstance(value, Enum): - result.append((field.name, value.value)) - elif value is not None and not field.name.startswith("_"): - result.append((field.name, value)) + result.append((f.name, value.value)) + # This if statement was added to the function + elif not isinstance(value, UndefinedType) and not f.name.startswith( + "_" + ): + result.append((f.name, value)) return dict(result) - if isinstance(model, tuple) and hasattr(model, "_fields"): - return type(model)(*[to_dict_without_none(v) for v in model]) + elif isinstance(obj, tuple) and hasattr(obj, "_fields"): + return type(obj)(*[_asdict_ignore_none(v) for v in obj]) - if isinstance(model, (list, tuple)): - return type(model)(to_dict_without_none(v) for v in model) + elif isinstance(obj, (list, tuple)): + return type(obj)(_asdict_ignore_none(v) for v in obj) - if isinstance(model, dict): - return type(model)( - (to_dict_without_none(k), to_dict_without_none(v)) for k, v in model.items() + elif isinstance(obj, dict): + return type(obj)( + (_asdict_ignore_none(k), _asdict_ignore_none(v)) + for k, v in obj.items() ) - - return copy.deepcopy(model) + else: + return copy.deepcopy(obj) class APIModelBase: @@ -65,12 +77,12 @@ class APIModelBase: Represents an object which has been fetched from the Discord API. """ - _client = None + _client: Optional[Any] = None @property def _http(self): if not self._client: - return None + raise AttributeError("Object is not yet linked to a client") return self._client.http @@ -78,17 +90,21 @@ class APIModelBase: def set_client(cls, client): cls._client = client - def __get_types(self, arg_type: type) -> Tuple[type]: + def __get_types(self, attr: str, arg_type: type) -> Tuple[type]: origin = get_origin(arg_type) if origin is Union: + # Ahh yes, typing module has no type annotations for this... # noinspection PyTypeChecker args: Tuple[type] = get_args(arg_type) if 2 <= len(args) < 4: return args - raise TypeError + raise ValueError( + f"Attribute `{attr}` in `{type(self).__name__}` has too many " + f"or not enough arguments! (got {len(args)} expected 2-3)" + ) return (arg_type,) @@ -99,8 +115,8 @@ class APIModelBase: if getattr(attr_type, "__factory__", None): factory = attr_type.__factory__ - if attr_value is None: - return None + if attr_value is UNDEFINED: + return UNDEFINED if attr_type is not None and isinstance(attr_value, attr_type): return attr_value @@ -108,9 +124,6 @@ class APIModelBase: if isinstance(attr_value, dict): return factory(attr_value) - if isinstance(attr_value, datetime.datetime): - return attr_value - return factory(attr_value) def __post_init__(self): @@ -126,14 +139,19 @@ class APIModelBase: if attr.startswith("_"): continue - types = self.__get_types(attr_type) + types = self.__get_types(attr, attr_type) types = tuple( - filter(lambda tpe: tpe is not None and tpe is not None, types) + filter( + lambda tpe: tpe is not None and tpe is not UNDEFINED, types + ) ) if not types: - raise TypeError + raise ValueError( + f"Attribute `{attr}` in `{type(self).__name__}` only " + "consisted of missing/optional type!" + ) specific_tp = types[0] @@ -143,7 +161,7 @@ class APIModelBase: specific_tp = tp if isinstance(specific_tp, EnumMeta) and not attr_gotten: - attr_value = None + attr_value = UNDEFINED elif tp == list and attr_gotten and (classes := get_args(types[0])): attr_value = [ self.__attr_convert(attr_item, classes[0]) @@ -179,23 +197,30 @@ class APIModelBase: return super().__str__() @classmethod - def from_dict(cls: Generic[T], data: Dict[str, Union[str, bool, int, Any]]) -> T: + def from_dict( + cls: Generic[T], data: Dict[str, Union[str, bool, int, Any]] + ) -> T: """ Parse an API object from a dictionary. """ if isinstance(data, cls): return data + # Disable inspection for IDE because this is valid code for the + # inherited classes: # noinspection PyArgumentList return cls( **dict( map( lambda key: ( key, - data[key].value if isinstance(data[key], Enum) else data[key], + data[key].value + if isinstance(data[key], Enum) + else data[key], ), filter( - lambda object_argument: data.get(object_argument) is not None, + lambda object_argument: data.get(object_argument) + is not None, getfullargspec(cls.__init__).args, ), ) @@ -203,4 +228,8 @@ class APIModelBase: ) def to_dict(self) -> Dict: - return to_dict_without_none(self) + """ + Transform the current object to a dictionary representation. Parameters that + start with an underscore are not serialized. + """ + return _asdict_ignore_none(self) diff --git a/melisa/utils/types.py b/melisa/utils/types.py index e9276cf..f445f69 100644 --- a/melisa/utils/types.py +++ b/melisa/utils/types.py @@ -35,9 +35,7 @@ T = TypeVar("T") Coro = TypeVar("Coro", bound=Callable[..., Coroutine[Any, Any, Any]]) -APINullable = Union[T, None] - -UndefinedOr = Union[T, UndefinedType] +APINullable = Union[T, UndefinedType] class Singleton(type): diff --git a/tests/test_colors.py b/tests/test_colors.py new file mode 100644 index 0000000..63842e4 --- /dev/null +++ b/tests/test_colors.py @@ -0,0 +1,15 @@ +from melisa import Color + + +rgb_right_example = (3, 217, 147) + + +class TestColor: + def test_from_rgb_converting(self): + assert Color.from_rgb(3, 217, 147).to_rgb() == rgb_right_example + + def test_from_hex_code_converting(self): + assert Color.from_hex_code("#03d993").to_rgb() == rgb_right_example + + def test_from_decimal_converting(self): + assert Color(252307).to_rgb() == rgb_right_example diff --git a/tests/test_embeds.py b/tests/test_embeds.py index a82f602..2e0b8cc 100644 --- a/tests/test_embeds.py +++ b/tests/test_embeds.py @@ -1,10 +1,11 @@ import datetime -from melisa import Embed, Timestamp +from melisa import Embed, Timestamp, Color dict_embed = { 'title': 'my title', 'description': 'simple description', + 'color': 252307, 'timestamp': datetime.datetime.utcfromtimestamp(1649748784).isoformat(), 'footer': { 'text': 'cool footer text' @@ -14,6 +15,16 @@ dict_embed = { }, } +EMBED = Embed(title="my title", description="simple description") +EMBED.set_author(name="best author") +EMBED.set_footer(text="cool footer text") +EMBED.set_color(Color.from_hex_code("#03d993")) +EMBED.set_timestamp(Timestamp.parse(1649748784)) + + +def has_key_vals(actual, required): + return all(actual.get(key) == val for key, val in required.items()) + class TestEmbed: def test_total_length_when_embed_is_empty(self): @@ -54,9 +65,15 @@ class TestEmbed: embed.set_footer(text="cool footer text") assert embed.total_length() == 53 - def test_comparing_embeds(self): - embed = Embed(title="my title", description="simple description") - embed.set_author(name="best author") - embed.set_footer(text="cool footer text") - embed.set_timestamp(Timestamp.parse(1649748784)) - assert embed.to_dict() == dict_embed + def test_embed_to_dict(self): + """ + Tests whether or not the dispatch class its string conversion + is correct. + """ + assert has_key_vals(EMBED.to_dict(), dict_embed) + + def test_embed_from_dict(self): + assert has_key_vals( + Embed.from_dict(dict_embed).to_dict(), + dict_embed + )