Rewrite Asset design

This is a breaking change.

This does the following transformations, assuming `asset` represents
an asset type.

Object.is_asset_animated() => Object.asset.is_animated()
Object.asset => Object.asset.key
Object.asset_url => Object.asset_url
Object.asset_url_as => Object.asset.replace(...)

Since the asset type now requires a key (or hash, if you will),
Emoji had to be flattened similar to how Attachment was done since
these assets are keyed solely ID.

Emoji.url (Asset) => Emoji.url (str)
Emoji.url_as => removed
Emoji.url.read => Emoji.read
Emoji.url.save => Emoji.save

This transformation was also done to PartialEmoji.
This commit is contained in:
Rapptz
2021-04-16 11:21:13 -04:00
parent 57dbb37a52
commit 9eaf1e85e4
14 changed files with 488 additions and 726 deletions

View File

@ -22,22 +22,28 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
import io
from typing import Literal, TYPE_CHECKING
import os
from typing import BinaryIO, Literal, TYPE_CHECKING, Tuple, Union
from .errors import DiscordException
from .errors import InvalidArgument
from . import utils
import yarl
__all__ = (
'Asset',
)
if TYPE_CHECKING:
ValidStaticFormatTypes = Literal['webp', 'jpeg', 'jpg', 'png']
ValidAvatarFormatTypes = Literal['webp', 'jpeg', 'jpg', 'png', 'gif']
ValidAssetFormatTypes = Literal['webp', 'jpeg', 'jpg', 'png', 'gif']
VALID_STATIC_FORMATS = frozenset({"jpeg", "jpg", "webp", "png"})
VALID_AVATAR_FORMATS = VALID_STATIC_FORMATS | {"gif"}
VALID_ASSET_FORMATS = VALID_STATIC_FORMATS | {"gif"}
class Asset:
"""Represents a CDN asset on Discord.
@ -52,10 +58,6 @@ class Asset:
Returns the length of the CDN asset's URL.
.. describe:: bool(x)
Checks if the Asset has a URL.
.. describe:: x == y
Checks if the asset is equal to another asset.
@ -68,96 +70,88 @@ class Asset:
Returns the hash of the asset.
"""
__slots__ = ('_state', '_url')
__slots__: Tuple[str, ...] = (
'_state',
'_url',
'_animated',
'_key',
)
BASE = 'https://cdn.discordapp.com'
def __init__(self, state, url=None):
def __init__(self, state, *, url: str, key: str, animated: bool = False):
self._state = state
self._url = url
self._animated = animated
self._key = key
@classmethod
def _from_avatar(cls, state, user, *, format=None, static_format='webp', size=1024):
if not utils.valid_icon_size(size):
raise InvalidArgument("size must be a power of 2 between 16 and 4096")
if format is not None and format not in VALID_AVATAR_FORMATS:
raise InvalidArgument(f"format must be None or one of {VALID_AVATAR_FORMATS}")
if format == "gif" and not user.is_avatar_animated():
raise InvalidArgument("non animated avatars do not support gif format")
if static_format not in VALID_STATIC_FORMATS:
raise InvalidArgument(f"static_format must be one of {VALID_STATIC_FORMATS}")
if user.avatar is None:
return user.default_avatar_url
if format is None:
format = 'gif' if user.is_avatar_animated() else static_format
return cls(state, f'/avatars/{user.id}/{user.avatar}.{format}?size={size}')
def _from_default_avatar(cls, state, index: int) -> Asset:
return cls(
state,
url=f'{cls.BASE}/embed/avatars/{index}.png',
key=str(index),
animated=False,
)
@classmethod
def _from_icon(cls, state, object, path, *, format='webp', size=1024):
if object.icon is None:
return cls(state)
if not utils.valid_icon_size(size):
raise InvalidArgument("size must be a power of 2 between 16 and 4096")
if format not in VALID_STATIC_FORMATS:
raise InvalidArgument(f"format must be None or one of {VALID_STATIC_FORMATS}")
url = f'/{path}-icons/{object.id}/{object.icon}.{format}?size={size}'
return cls(state, url)
def _from_avatar(cls, state, user_id: int, avatar: str) -> Asset:
animated = avatar.startswith('a_')
format = 'gif' if animated else 'png'
return cls(
state,
url=f'{cls.BASE}/avatars/{user_id}/{avatar}.{format}?size=1024',
key=avatar,
animated=animated,
)
@classmethod
def _from_cover_image(cls, state, obj, *, format='webp', size=1024):
if obj.cover_image is None:
return cls(state)
if not utils.valid_icon_size(size):
raise InvalidArgument("size must be a power of 2 between 16 and 4096")
if format not in VALID_STATIC_FORMATS:
raise InvalidArgument(f"format must be None or one of {VALID_STATIC_FORMATS}")
url = f'/app-assets/{obj.id}/store/{obj.cover_image}.{format}?size={size}'
return cls(state, url)
def _from_icon(cls, state, object_id: int, icon_hash: str, path: str) -> Asset:
return cls(
state,
url=f'{cls.BASE}/{path}-icons/{object_id}/{icon_hash}.png?size=1024',
key=icon_hash,
animated=False,
)
@classmethod
def _from_guild_image(cls, state, id, hash, key, *, format='webp', size=1024):
if not utils.valid_icon_size(size):
raise InvalidArgument("size must be a power of 2 between 16 and 4096")
if format not in VALID_STATIC_FORMATS:
raise InvalidArgument(f"format must be one of {VALID_STATIC_FORMATS}")
if hash is None:
return cls(state)
return cls(state, f'/{key}/{id}/{hash}.{format}?size={size}')
def _from_cover_image(cls, state, object_id: int, cover_image_hash: str) -> Asset:
return cls(
state,
url=f'{cls.BASE}/app-assets/{object_id}/store/{cover_image_hash}.png?size=1024',
key=cover_image_hash,
animated=False,
)
@classmethod
def _from_guild_icon(cls, state, guild, *, format=None, static_format='webp', size=1024):
if not utils.valid_icon_size(size):
raise InvalidArgument("size must be a power of 2 between 16 and 4096")
if format is not None and format not in VALID_AVATAR_FORMATS:
raise InvalidArgument(f"format must be one of {VALID_AVATAR_FORMATS}")
if format == "gif" and not guild.is_icon_animated():
raise InvalidArgument("non animated guild icons do not support gif format")
if static_format not in VALID_STATIC_FORMATS:
raise InvalidArgument(f"static_format must be one of {VALID_STATIC_FORMATS}")
if guild.icon is None:
return cls(state)
if format is None:
format = 'gif' if guild.is_icon_animated() else static_format
return cls(state, f'/icons/{guild.id}/{guild.icon}.{format}?size={size}')
def _from_guild_image(cls, state, guild_id: int, image: str, path: str) -> Asset:
return cls(
state,
url=f'{cls.BASE}/{path}/{guild_id}/{image}.png?size=1024',
key=image,
animated=False,
)
@classmethod
def _from_sticker_url(cls, state, sticker, *, size=1024):
if not utils.valid_icon_size(size):
raise InvalidArgument("size must be a power of 2 between 16 and 4096")
def _from_guild_icon(cls, state, guild_id: int, icon_hash: str) -> Asset:
animated = icon_hash.startswith('a_')
format = 'gif' if animated else 'png'
return cls(
state,
url=f'{cls.BASE}/icons/{guild_id}/{icon_hash}.{format}?size=1024',
key=icon_hash,
animated=animated,
)
return cls(state, f'/stickers/{sticker.id}/{sticker.image}.png?size={size}')
@classmethod
def _from_sticker(cls, state, sticker_id: int, sticker_hash: str) -> Asset:
return cls(
state,
url=f'{cls.BASE}/stickers/{sticker_id}/{sticker_hash}.png?size=1024',
key=sticker_hash,
animated=False,
)
@classmethod
def _from_emoji(cls, state, emoji, *, format=None, static_format='png'):
@ -172,46 +166,184 @@ class Asset:
return cls(state, f'/emojis/{emoji.id}.{format}')
def __str__(self):
return self.BASE + self._url if self._url is not None else ''
def __str__(self) -> str:
return self._url
def __len__(self):
if self._url:
return len(self.BASE + self._url)
return 0
def __bool__(self):
return self._url is not None
def __len__(self) -> int:
return len(self._url)
def __repr__(self):
return f'<Asset url={self._url!r}>'
shorten = self._url.replace(self.BASE, '')
return f'<Asset url={shorten!r}>'
def __eq__(self, other):
return isinstance(other, Asset) and self._url == other._url
def __ne__(self, other):
return not self.__eq__(other)
def __hash__(self):
return hash(self._url)
async def read(self):
@property
def url(self) -> str:
""":class:`str`: Returns the underlying URL of the asset."""
return self._url
@property
def key(self) -> str:
""":class:`str`: Returns the identifying key of the asset."""
return self._key
def is_animated(self) -> bool:
""":class:`bool`: Returns whether the asset is animated."""
return self._animated
def replace(
self,
size: int = ...,
format: ValidAssetFormatTypes = ...,
static_format: ValidStaticFormatTypes = ...,
) -> Asset:
"""Returns a new asset with the passed components replaced.
Parameters
-----------
size: :class:`int`
The new size of the asset.
format: :class:`str`
The new format to change it to. Must be either
'webp', 'jpeg', 'jpg', 'png', or 'gif' if it's animated.
static_format: :class:`str`
The new format to change it to if the asset isn't animated.
Must be either 'webp', 'jpeg', 'jpg', or 'png'.
Raises
-------
InvalidArgument
An invalid size or format was passed.
Returns
--------
:class:`Asset`
The newly updated asset.
"""
url = yarl.URL(self._url)
path, _ = os.path.splitext(url.path)
if format is not ...:
if self._animated:
if format not in VALID_ASSET_FORMATS:
raise InvalidArgument(f'format must be one of {VALID_ASSET_FORMATS}')
else:
if format not in VALID_STATIC_FORMATS:
raise InvalidArgument(f'format must be one of {VALID_STATIC_FORMATS}')
url = url.with_path(f'{path}.{format}')
if static_format is not ... and not self._animated:
if static_format not in VALID_STATIC_FORMATS:
raise InvalidArgument(f'static_format must be one of {VALID_STATIC_FORMATS}')
url = url.with_path(f'{path}.{static_format}')
if size is not ...:
if not utils.valid_icon_size(size):
raise InvalidArgument('size must be a power of 2 between 16 and 4096')
url = url.with_query(size=size)
else:
url = url.with_query(url.raw_query_string)
url = str(url)
return Asset(state=self._state, url=url, key=self._key, animated=self._animated)
def with_size(self, size: int) -> Asset:
"""Returns a new asset with the specified size.
Parameters
------------
size: :class:`int`
The new size of the asset.
Raises
-------
InvalidArgument
The asset had an invalid size.
Returns
--------
:class:`Asset`
The new updated asset.
"""
if not utils.valid_icon_size(size):
raise InvalidArgument('size must be a power of 2 between 16 and 4096')
url = str(yarl.URL(self._url).with_query(size=size))
return Asset(state=self._state, url=url, key=self._key, animated=self._animated)
def with_format(self, format: ValidAssetFormatTypes) -> Asset:
"""Returns a new asset with the specified format.
Parameters
------------
format: :class:`str`
The new format of the asset.
Raises
-------
InvalidArgument
The asset had an invalid format.
Returns
--------
:class:`Asset`
The new updated asset.
"""
if self._animated:
if format not in VALID_ASSET_FORMATS:
raise InvalidArgument(f'format must be one of {VALID_ASSET_FORMATS}')
else:
if format not in VALID_STATIC_FORMATS:
raise InvalidArgument(f'format must be one of {VALID_STATIC_FORMATS}')
url = yarl.URL(self._url)
path, _ = os.path.splitext(url.path)
url = str(url.with_path(f'{path}.{format}').with_query(url.raw_query_string))
return Asset(state=self._state, url=url, key=self._key, animated=self._animated)
def with_static_format(self, format: ValidStaticFormatTypes) -> Asset:
"""Returns a new asset with the specified static format.
This only changes the format if the underlying asset is
not animated. Otherwise, the asset is not changed.
Parameters
------------
format: :class:`str`
The new static format of the asset.
Raises
-------
InvalidArgument
The asset had an invalid format.
Returns
--------
:class:`Asset`
The new updated asset.
"""
if self._animated:
return self
return self.with_format(format)
async def read(self) -> bytes:
"""|coro|
Retrieves the content of this asset as a :class:`bytes` object.
.. warning::
:class:`PartialEmoji` won't have a connection state if user created,
and a URL won't be present if a custom image isn't associated with
the asset, e.g. a guild with no custom icon.
.. versionadded:: 1.1
Raises
------
DiscordException
There was no valid URL or internal connection state.
There was no internal connection state.
HTTPException
Downloading the asset failed.
NotFound
@ -222,15 +354,12 @@ class Asset:
:class:`bytes`
The content of the asset.
"""
if not self._url:
raise DiscordException('Invalid asset (no URL provided)')
if self._state is None:
raise DiscordException('Invalid state (no ConnectionState provided)')
return await self._state.http.get_from_cdn(self.BASE + self._url)
async def save(self, fp, *, seek_begin=True):
async def save(self, fp: Union[str, bytes, os.PathLike, BinaryIO], *, seek_begin: bool = True) -> int:
"""|coro|
Saves this asset into a file-like object.
@ -245,7 +374,7 @@ class Asset:
Raises
------
DiscordException
There was no valid URL or internal connection state.
There was no internal connection state.
HTTPException
Downloading the asset failed.
NotFound