diff --git a/discord/embeds.py b/discord/embeds.py index 88a8117e6..c44d8b9ec 100644 --- a/discord/embeds.py +++ b/discord/embeds.py @@ -22,7 +22,10 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ +from __future__ import annotations + import datetime +from typing import Any, Dict, Final, List, Protocol, TYPE_CHECKING, Type, TypeVar, Union from . import utils from .colour import Colour @@ -31,32 +34,75 @@ __all__ = ( 'Embed', ) + class _EmptyEmbed: - def __bool__(self): + def __bool__(self) -> bool: return False - def __repr__(self): + def __repr__(self) -> str: return 'Embed.Empty' - def __len__(self): + def __len__(self) -> int: return 0 -EmptyEmbed = _EmptyEmbed() + +EmptyEmbed: Final = _EmptyEmbed() + class EmbedProxy: - def __init__(self, layer): + def __init__(self, layer: Dict[str, Any]): self.__dict__.update(layer) - def __len__(self): + def __len__(self) -> int: return len(self.__dict__) - def __repr__(self): + def __repr__(self) -> str: inner = ', '.join((f'{k}={v!r}' for k, v in self.__dict__.items() if not k.startswith('_'))) return f'EmbedProxy({inner})' - def __getattr__(self, attr): + def __getattr__(self, attr: str) -> _EmptyEmbed: return EmptyEmbed + +E = TypeVar('E', bound='Embed') + +if TYPE_CHECKING: + from discord.types.common import Embed as EmbedData, EmbedType + + T = TypeVar('T') + MaybeEmpty = Union[T, _EmptyEmbed] + + class _EmbedFooterProxy(Protocol): + text: MaybeEmpty[str] + icon_url: MaybeEmpty[str] + + class _EmbedFieldProxy(Protocol): + name: MaybeEmpty[str] + value: MaybeEmpty[str] + inline: bool + + class _EmbedMediaProxy(Protocol): + url: MaybeEmpty[str] + proxy_url: MaybeEmpty[str] + height: MaybeEmpty[int] + width: MaybeEmpty[int] + + class _EmbedVideoProxy(Protocol): + url: MaybeEmpty[str] + height: MaybeEmpty[int] + width: MaybeEmpty[int] + + class _EmbedProviderProxy(Protocol): + name: MaybeEmpty[str] + url: MaybeEmpty[str] + + class _EmbedAuthorProxy(Protocol): + name: MaybeEmpty[str] + url: MaybeEmpty[str] + icon_url: MaybeEmpty[str] + proxy_icon_url: MaybeEmpty[str] + + class Embed: """Represents a Discord embed. @@ -108,24 +154,41 @@ class Embed: to denote that the value or attribute is empty. """ - __slots__ = ('title', 'url', 'type', '_timestamp', '_colour', '_footer', - '_image', '_thumbnail', '_video', '_provider', '_author', - '_fields', 'description') - - Empty = EmptyEmbed - - def __init__(self, **kwargs): - # swap the colour/color aliases - try: - colour = kwargs['colour'] - except KeyError: - colour = kwargs.get('color', EmptyEmbed) - - self.colour = colour - self.title = kwargs.get('title', EmptyEmbed) - self.type = kwargs.get('type', 'rich') - self.url = kwargs.get('url', EmptyEmbed) - self.description = kwargs.get('description', EmptyEmbed) + __slots__ = ( + 'title', + 'url', + 'type', + '_timestamp', + '_colour', + '_footer', + '_image', + '_thumbnail', + '_video', + '_provider', + '_author', + '_fields', + 'description', + ) + + Empty: Final = EmptyEmbed + + def __init__( + self, + *, + colour: Union[int, Colour, _EmptyEmbed] = EmptyEmbed, + color: Union[int, Colour, _EmptyEmbed] = EmptyEmbed, + title: MaybeEmpty[str] = EmptyEmbed, + type: EmbedType = 'rich', + url: MaybeEmpty[str] = EmptyEmbed, + description: MaybeEmpty[str] = EmptyEmbed, + timestamp: datetime.datetime = None, + ): + + self.colour = colour if colour is not EmptyEmbed else color + self.title = title + self.type = type + self.url = url + self.description = description if self.title is not EmptyEmbed: self.title = str(self.title) @@ -136,17 +199,13 @@ class Embed: if self.url is not EmptyEmbed: self.url = str(self.url) - try: - timestamp = kwargs['timestamp'] - except KeyError: - pass - else: + if timestamp: if timestamp.tzinfo is None: timestamp = timestamp.astimezone() self.timestamp = timestamp @classmethod - def from_dict(cls, data): + def from_dict(cls: Type[E], data: EmbedData) -> E: """Converts a :class:`dict` to a :class:`Embed` provided it is in the format that Discord expects it to be in. @@ -162,7 +221,7 @@ class Embed: The dictionary to convert into an embed. """ # we are bypassing __init__ here since it doesn't apply here - self = cls.__new__(cls) + self: E = cls.__new__(cls) # fill in the basic fields @@ -202,11 +261,11 @@ class Embed: return self - def copy(self): + def copy(self: E) -> E: """Returns a shallow copy of the embed.""" - return Embed.from_dict(self.to_dict()) + return self.__class__.from_dict(self.to_dict()) - def __len__(self): + def __len__(self) -> int: total = len(self.title) + len(self.description) for field in getattr(self, '_fields', []): total += len(field['name']) + len(field['value']) @@ -227,28 +286,30 @@ class Embed: return total - def __bool__(self): - return any(( - self.title, - self.url, - self.description, - self.colour, - self.fields, - self.timestamp, - self.author, - self.thumbnail, - self.footer, - self.image, - self.provider, - self.video, - )) + def __bool__(self) -> bool: + return any( + ( + self.title, + self.url, + self.description, + self.colour, + self.fields, + self.timestamp, + self.author, + self.thumbnail, + self.footer, + self.image, + self.provider, + self.video, + ) + ) @property - def colour(self): + def colour(self) -> MaybeEmpty[Colour]: return getattr(self, '_colour', EmptyEmbed) @colour.setter - def colour(self, value): + def colour(self, value: Union[int, Colour, _EmptyEmbed]): # type: ignore if isinstance(value, (Colour, _EmptyEmbed)): self._colour = value elif isinstance(value, int): @@ -259,27 +320,27 @@ class Embed: color = colour @property - def timestamp(self): + def timestamp(self) -> MaybeEmpty[datetime.datetime]: return getattr(self, '_timestamp', EmptyEmbed) @timestamp.setter - def timestamp(self, value): + def timestamp(self, value: MaybeEmpty[datetime.datetime]): if isinstance(value, (datetime.datetime, _EmptyEmbed)): self._timestamp = value else: raise TypeError(f"Expected datetime.datetime or Embed.Empty received {value.__class__.__name__} instead") @property - def footer(self): - """Union[:class:`EmbedProxy`, :attr:`Empty`]: Returns an ``EmbedProxy`` denoting the footer contents. + def footer(self) -> _EmbedFooterProxy: + """Returns an ``EmbedProxy`` denoting the footer contents. See :meth:`set_footer` for possible values you can access. If the attribute has no value then :attr:`Empty` is returned. """ - return EmbedProxy(getattr(self, '_footer', {})) + return EmbedProxy(getattr(self, '_footer', {})) # type: ignore - def set_footer(self, *, text=EmptyEmbed, icon_url=EmptyEmbed): + def set_footer(self: E, *, text: MaybeEmpty[str] = EmptyEmbed, icon_url: MaybeEmpty[str] = EmptyEmbed) -> E: """Sets the footer for the embed content. This function returns the class instance to allow for fluent-style @@ -303,8 +364,8 @@ class Embed: return self @property - def image(self): - """Union[:class:`EmbedProxy`, :attr:`Empty`]: Returns an ``EmbedProxy`` denoting the image contents. + def image(self) -> _EmbedMediaProxy: + """Returns an ``EmbedProxy`` denoting the image contents. Possible attributes you can access are: @@ -315,9 +376,9 @@ class Embed: If the attribute has no value then :attr:`Empty` is returned. """ - return EmbedProxy(getattr(self, '_image', {})) + return EmbedProxy(getattr(self, '_image', {})) # type: ignore - def set_image(self, *, url): + def set_image(self: E, *, url: MaybeEmpty[str]) -> E: """Sets the image for the embed content. This function returns the class instance to allow for fluent-style @@ -339,14 +400,14 @@ class Embed: pass else: self._image = { - 'url': str(url) + 'url': str(url), } return self @property - def thumbnail(self): - """Union[:class:`EmbedProxy`, :attr:`Empty`]: Returns an ``EmbedProxy`` denoting the thumbnail contents. + def thumbnail(self) -> _EmbedMediaProxy: + """Returns an ``EmbedProxy`` denoting the thumbnail contents. Possible attributes you can access are: @@ -357,9 +418,9 @@ class Embed: If the attribute has no value then :attr:`Empty` is returned. """ - return EmbedProxy(getattr(self, '_thumbnail', {})) + return EmbedProxy(getattr(self, '_thumbnail', {})) # type: ignore - def set_thumbnail(self, *, url): + def set_thumbnail(self: E, *, url: MaybeEmpty[str]) -> E: """Sets the thumbnail for the embed content. This function returns the class instance to allow for fluent-style @@ -381,14 +442,14 @@ class Embed: pass else: self._thumbnail = { - 'url': str(url) + 'url': str(url), } return self @property - def video(self): - """Union[:class:`EmbedProxy`, :attr:`Empty`]: Returns an ``EmbedProxy`` denoting the video contents. + def video(self) -> _EmbedVideoProxy: + """Returns an ``EmbedProxy`` denoting the video contents. Possible attributes include: @@ -398,29 +459,29 @@ class Embed: If the attribute has no value then :attr:`Empty` is returned. """ - return EmbedProxy(getattr(self, '_video', {})) + return EmbedProxy(getattr(self, '_video', {})) # type: ignore @property - def provider(self): - """Union[:class:`EmbedProxy`, :attr:`Empty`]: Returns an ``EmbedProxy`` denoting the provider contents. + def provider(self) -> _EmbedProviderProxy: + """Returns an ``EmbedProxy`` denoting the provider contents. The only attributes that might be accessed are ``name`` and ``url``. If the attribute has no value then :attr:`Empty` is returned. """ - return EmbedProxy(getattr(self, '_provider', {})) + return EmbedProxy(getattr(self, '_provider', {})) # type: ignore @property - def author(self): - """Union[:class:`EmbedProxy`, :attr:`Empty`]: Returns an ``EmbedProxy`` denoting the author contents. + def author(self) -> _EmbedAuthorProxy: + """Returns an ``EmbedProxy`` denoting the author contents. See :meth:`set_author` for possible values you can access. If the attribute has no value then :attr:`Empty` is returned. """ - return EmbedProxy(getattr(self, '_author', {})) + return EmbedProxy(getattr(self, '_author', {})) # type: ignore - def set_author(self, *, name, url=EmptyEmbed, icon_url=EmptyEmbed): + def set_author(self: E, *, name: str, url: MaybeEmpty[str] = EmptyEmbed, icon_url: MaybeEmpty[str] = EmptyEmbed) -> E: """Sets the author for the embed content. This function returns the class instance to allow for fluent-style @@ -437,7 +498,7 @@ class Embed: """ self._author = { - 'name': str(name) + 'name': str(name), } if url is not EmptyEmbed: @@ -448,7 +509,7 @@ class Embed: return self - def remove_author(self): + def remove_author(self: E) -> E: """Clears embed's author information. This function returns the class instance to allow for fluent-style @@ -464,16 +525,16 @@ class Embed: return self @property - def fields(self): + def fields(self) -> List[_EmbedFieldProxy]: """Union[List[:class:`EmbedProxy`], :attr:`Empty`]: Returns a :class:`list` of ``EmbedProxy`` denoting the field contents. See :meth:`add_field` for possible values you can access. If the attribute has no value then :attr:`Empty` is returned. """ - return [EmbedProxy(d) for d in getattr(self, '_fields', [])] + return [EmbedProxy(d) for d in getattr(self, '_fields', [])] # type: ignore - def add_field(self, *, name, value, inline=True): + def add_field(self: E, *, name: str, value: str, inline: bool = True) -> E: """Adds a field to the embed object. This function returns the class instance to allow for fluent-style @@ -492,7 +553,7 @@ class Embed: field = { 'inline': inline, 'name': str(name), - 'value': str(value) + 'value': str(value), } try: @@ -502,7 +563,7 @@ class Embed: return self - def insert_field_at(self, index, *, name, value, inline=True): + def insert_field_at(self: E, index: int, *, name: str, value: str, inline: bool = True) -> E: """Inserts a field before a specified index to the embed. This function returns the class instance to allow for fluent-style @@ -525,7 +586,7 @@ class Embed: field = { 'inline': inline, 'name': str(name), - 'value': str(value) + 'value': str(value), } try: @@ -535,14 +596,14 @@ class Embed: return self - def clear_fields(self): + def clear_fields(self) -> None: """Removes all fields from this embed.""" try: self._fields.clear() except AttributeError: self._fields = [] - def remove_field(self, index): + def remove_field(self, index: int) -> None: """Removes a field at a specified index. If the index is invalid or out of bounds then the error is @@ -563,7 +624,7 @@ class Embed: except (AttributeError, IndexError): pass - def set_field_at(self, index, *, name, value, inline=True): + def set_field_at(self: E, index: int, *, name: str, value: str, inline: bool = True) -> E: """Modifies a field to the embed object. The index must point to a valid pre-existing field. @@ -598,15 +659,17 @@ class Embed: field['inline'] = inline return self - def to_dict(self): + def to_dict(self) -> EmbedData: """Converts this embed object into a dict.""" # add in the raw data into the dict + # fmt: off result = { key[1:]: getattr(self, key) for key in self.__slots__ if key[0] == '_' and hasattr(self, key) } + # fmt: on # deal with basic convenience wrappers @@ -642,4 +705,4 @@ class Embed: if self.title: result['title'] = self.title - return result + return result # type: ignore diff --git a/discord/types/__init__.py b/discord/types/__init__.py new file mode 100644 index 000000000..caa9706d3 --- /dev/null +++ b/discord/types/__init__.py @@ -0,0 +1,10 @@ +""" +discord.types +~~~~~~~~~~~~~~ + +Typings for the Discord API + +:copyright: (c) 2015-present Rapptz +:license: MIT, see LICENSE for more details. + +""" diff --git a/discord/types/common.py b/discord/types/common.py new file mode 100644 index 000000000..4b4a3e8fb --- /dev/null +++ b/discord/types/common.py @@ -0,0 +1,86 @@ +""" +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. +""" + +# This file provides typings for shared entities between the Gateway and HTTP API + +from typing import List, Literal, TypedDict + +class _EmbedFooterOptional(TypedDict, total=False): + icon_url: str + proxy_icon_url: str + +class EmbedFooter(_EmbedFooterOptional): + text: str + +class _EmbedFieldOptional(TypedDict, total=False): + inline: bool + +class EmbedField(_EmbedFieldOptional): + name: str + value: str + +class EmbedThumbnail(TypedDict, total=False): + url: str + proxy_url: str + height: int + width: int + +class EmbedVideo(TypedDict, total=False): + url: str + proxy_url: str + height: int + width: int + +class EmbedImage(TypedDict, total=False): + url: str + proxy_url: str + height: int + width: int + +class EmbedProvider(TypedDict, total=False): + name: str + url: str + +class EmbedAuthor(TypedDict, total=False): + name: str + url: str + icon_url: str + proxy_icon_url: str + +EmbedType = Literal['rich', 'image', 'video', 'gifv', 'article', 'link'] + +class Embed(TypedDict, total=False): + title: str + type: EmbedType + description: str + url: str + timestamp: str + color: int + footer: EmbedFooter + image: EmbedImage + thumbnail: EmbedThumbnail + video: EmbedVideo + provider: EmbedProvider + author: EmbedAuthor + fields: List[EmbedField] diff --git a/setup.py b/setup.py index 3c5f94aa6..5a8b55248 100644 --- a/setup.py +++ b/setup.py @@ -50,7 +50,7 @@ setup(name='discord.py', "Issue tracker": "https://github.com/Rapptz/discord.py/issues", }, version=version, - packages=['discord', 'discord.ext.commands', 'discord.ext.tasks'], + packages=['discord', 'discord.types', 'discord.ext.commands', 'discord.ext.tasks'], license='MIT', description='A Python wrapper for the Discord API', long_description=readme,