diff --git a/discord/embeds.py b/discord/embeds.py index da72932373..c41a7b51c2 100644 --- a/discord/embeds.py +++ b/discord/embeds.py @@ -33,6 +33,7 @@ Final, List, Mapping, + Optional, Protocol, Type, TypeVar, @@ -42,7 +43,10 @@ from . import utils from .colour import Colour -__all__ = ("Embed",) +__all__ = ( + "Embed", + "EmbedField", +) class _EmptyEmbed: @@ -87,11 +91,6 @@ 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] @@ -114,6 +113,59 @@ class _EmbedAuthorProxy(Protocol): proxy_icon_url: MaybeEmpty[str] +class EmbedField: + """Represents a field on the :class:`Embed` object. + + .. versionadded:: 2.0 + + Attributes + ---------- + name: :class:`str` + The name of the field. + value: :class:`str` + The value of the field. + inline: :class:`bool` + Whether the field should be displayed inline. + """ + + def __init__(self, name: str, value: str, inline: Optional[bool] = False): + self.name = name + self.value = value + self.inline = inline + + @classmethod + def from_dict(cls: Type[E], data: Mapping[str, Any]) -> E: + """Converts a :class:`dict` to a :class:`EmbedField` provided it is in the + format that Discord expects it to be in. + + You can find out about this format in the `official Discord documentation`__. + + .. _DiscordDocsEF: https://discord.com/developers/docs/resources/channel#embed-object-embed-field-structure + + __ DiscordDocsEF_ + + Parameters + ----------- + data: :class:`dict` + The dictionary to convert into an EmbedField object. + """ + self: E = cls.__new__(cls) + + self.name = data["name"] + self.value = data["value"] + self.inline = data.get("inline", False) + + return self + + def to_dict(self) -> Dict[str, Union[str, bool]]: + """Converts this EmbedField object into a dict.""" + return { + "name": self.name, + "value": self.value, + "inline": self.inline, + } + + class Embed: """Represents a Discord embed. @@ -137,7 +189,7 @@ class Embed: :attr:`Embed.Empty`. For ease of use, all parameters that expect a :class:`str` are implicitly - casted to :class:`str` for you. + cast to :class:`str` for you. Attributes ----------- @@ -195,6 +247,7 @@ def __init__( url: MaybeEmpty[Any] = EmptyEmbed, description: MaybeEmpty[Any] = EmptyEmbed, timestamp: datetime.datetime = None, + fields: Optional[List[EmbedField]] = None, ): self.colour = colour if colour is not EmptyEmbed else color @@ -214,6 +267,7 @@ def __init__( if timestamp: self.timestamp = timestamp + self._fields: List[EmbedField] = fields or [] @classmethod def from_dict(cls: Type[E], data: Mapping[str, Any]) -> E: @@ -271,12 +325,16 @@ def from_dict(cls: Type[E], data: Mapping[str, Any]) -> E: "image", "footer", ): - try: - value = data[attr] - except KeyError: - continue + if attr == "fields": + value = data.get(attr, []) + self._fields = [EmbedField.from_dict(d) for d in value] if value else [] else: - setattr(self, f"_{attr}", value) + try: + value = data[attr] + except KeyError: + continue + else: + setattr(self, f"_{attr}", value) return self @@ -287,7 +345,7 @@ def copy(self: E) -> E: def __len__(self) -> int: total = len(self.title) + len(self.description) for field in getattr(self, "_fields", []): - total += len(field["name"]) + len(field["value"]) + total += len(field.name) + len(field.value) try: footer_text = self._footer["text"] @@ -606,16 +664,50 @@ def remove_author(self: E) -> E: return self @property - def fields(self) -> List[_EmbedFieldProxy]: - """List[Union[``EmbedProxy``, :attr:`Empty`]]: Returns a :class:`list` of ``EmbedProxy`` denoting the field contents. + def fields(self) -> Optional[List[EmbedField]]: + """Returns a :class:`list` of :class:`EmbedField` objects 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. + If the attribute has no value then ``None`` is returned. + """ + if self._fields: + return self._fields + else: + return None + + @fields.setter + def fields(self, value: List[EmbedField]) -> None: + """Sets the fields for the embed. This overwrites any existing fields. + + Parameters + ---------- + value: List[:class:`EmbedField`] + The list of :class:`EmbedField` objects to include in the embed. + """ + if not all(isinstance(x, EmbedField) for x in value): + raise TypeError("Expected a list of EmbedField objects.") + + self.clear_fields() + for field in value: + self.add_field(name=field.name, value=field.value, inline=field.inline) + + def append_field(self, field: EmbedField) -> None: + """Appends an :class:`EmbedField` object to the embed. + + .. versionadded:: 2.0 + + Parameters + ---------- + field: :class:`EmbedField` + The field to add. """ - return [EmbedProxy(d) for d in getattr(self, "_fields", [])] # type: ignore + if not isinstance(field, EmbedField): + raise TypeError("Expected an EmbedField object.") + + self._fields.append(field) - def add_field(self: E, *, name: Any, value: Any, inline: bool = True) -> E: + 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 @@ -630,17 +722,7 @@ def add_field(self: E, *, name: Any, value: Any, inline: bool = True) -> E: inline: :class:`bool` Whether the field should be displayed inline. """ - - field = { - "inline": inline, - "name": str(name), - "value": str(value), - } - - try: - self._fields.append(field) - except AttributeError: - self._fields = [field] + self._fields.append(EmbedField(name=str(name), value=str(value), inline=inline)) return self @@ -664,16 +746,9 @@ def insert_field_at(self: E, index: int, *, name: Any, value: Any, inline: bool Whether the field should be displayed inline. """ - field = { - "inline": inline, - "name": str(name), - "value": str(value), - } + field = EmbedField(name=str(name), value=str(value), inline=inline) - try: - self._fields.insert(index, field) - except AttributeError: - self._fields = [field] + self._fields.insert(index, field) return self @@ -702,7 +777,7 @@ def remove_field(self, index: int) -> None: """ try: del self._fields[index] - except (AttributeError, IndexError): + except IndexError: pass def set_field_at(self: E, index: int, *, name: Any, value: Any, inline: bool = True) -> E: @@ -732,19 +807,26 @@ def set_field_at(self: E, index: int, *, name: Any, value: Any, inline: bool = T try: field = self._fields[index] - except (TypeError, IndexError, AttributeError): + except (TypeError, IndexError): raise IndexError("field index out of range") - field["name"] = str(name) - field["value"] = str(value) - field["inline"] = inline + field.name = str(name) + field.value = str(value) + field.inline = inline return self def to_dict(self) -> EmbedData: """Converts this embed object into a dict.""" # add in the raw data into the dict - result = {key[1:]: getattr(self, key) for key in self.__slots__ if key[0] == "_" and hasattr(self, key)} + result = { + key[1:]: getattr(self, key) + for key in self.__slots__ + if key != "_fields" and key[0] == "_" and hasattr(self, key) + } + + # add in the fields + result["fields"] = [field.to_dict() for field in self._fields] # deal with basic convenience wrappers @@ -767,7 +849,7 @@ def to_dict(self) -> EmbedData: else: result["timestamp"] = timestamp.replace(tzinfo=datetime.timezone.utc).isoformat() - # add in the non raw attribute ones + # add in the non-raw attribute ones if self.type: result["type"] = self.type diff --git a/docs/api.rst b/docs/api.rst index adbfcd1026..d1a7d4b067 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -4673,6 +4673,14 @@ Embed .. autoclass:: Embed :members: +EmbedField +~~~~~~~~~~ + +.. attributetable:: EmbedField + +.. autoclass:: EmbedField + :members: + AllowedMentions ~~~~~~~~~~~~~~~~~