diff --git a/toot/entities.py b/toot/entities.py new file mode 100644 index 0000000..c1a49b7 --- /dev/null +++ b/toot/entities.py @@ -0,0 +1,307 @@ +""" +Dataclasses which represent entities returned by the Mastodon API. +""" + +import dataclasses + +from dataclasses import dataclass, is_dataclass +from datetime import date, datetime +from typing import Dict, List, Optional, Type, TypeVar, Union +from typing import get_type_hints + +from toot.typing_compat import get_args, get_origin +from toot.utils import get_text + + +@dataclass +class AccountField: + """ + https://docs.joinmastodon.org/entities/Account/#Field + """ + name: str + value: str + verified_at: Optional[datetime] + + +@dataclass +class CustomEmoji: + """ + https://docs.joinmastodon.org/entities/CustomEmoji/ + """ + shortcode: str + url: str + static_url: str + visible_in_picker: bool + category: str + + +@dataclass +class Account: + """ + https://docs.joinmastodon.org/entities/Account/ + """ + id: str + username: str + acct: str + url: str + display_name: str + note: str + avatar: str + avatar_static: str + header: str + header_static: str + locked: bool + fields: List[AccountField] + emojis: List[CustomEmoji] + bot: bool + group: bool + discoverable: Optional[bool] + noindex: Optional[bool] + moved: Optional["Account"] + suspended: Optional[bool] + limited: Optional[bool] + created_at: datetime + last_status_at: Optional[date] + statuses_count: int + followers_count: int + following_count: int + + @property + def note_plaintext(self) -> str: + return get_text(self.note) + + +@dataclass +class Application: + """ + https://docs.joinmastodon.org/entities/Status/#application + """ + name: str + website: Optional[str] + + +@dataclass +class MediaAttachment: + """ + https://docs.joinmastodon.org/entities/MediaAttachment/ + """ + id: str + type: str + url: str + preview_url: str + remote_url: Optional[str] + meta: dict + description: str + blurhash: str + + +@dataclass +class StatusMention: + """ + https://docs.joinmastodon.org/entities/Status/#Mention + """ + id: str + username: str + url: str + acct: str + + +@dataclass +class StatusTag: + """ + https://docs.joinmastodon.org/entities/Status/#Tag + """ + name: str + url: str + + +@dataclass +class PollOption: + """ + https://docs.joinmastodon.org/entities/Poll/#Option + """ + title: str + votes_count: Optional[int] + + +@dataclass +class Poll: + """ + https://docs.joinmastodon.org/entities/Poll/ + """ + id: str + expires_at: Optional[datetime] + expired: bool + multiple: bool + votes_count: int + voters_count: Optional[int] + options: List[PollOption] + emojis: List[CustomEmoji] + voted: Optional[bool] + own_votes: Optional[List[int]] + + +@dataclass +class PreviewCard: + """ + https://docs.joinmastodon.org/entities/PreviewCard/ + """ + url: str + title: str + description: str + type: str + author_name: str + author_url: str + provider_name: str + provider_url: str + html: str + width: int + height: int + image: Optional[str] + embed_url: str + blurhash: Optional[str] + + +@dataclass +class FilterKeyword: + """ + https://docs.joinmastodon.org/entities/FilterKeyword/ + """ + id: str + keyword: str + whole_word: str + + +@dataclass +class FilterStatus: + """ + https://docs.joinmastodon.org/entities/FilterStatus/ + """ + id: str + status_id: str + + +@dataclass +class Filter: + """ + https://docs.joinmastodon.org/entities/Filter/ + """ + id: str + title: str + context: List[str] + expires_at: Optional[datetime] + filter_action: str + keywords: List[FilterKeyword] + statuses: List[FilterStatus] + + +@dataclass +class FilterResult: + """ + https://docs.joinmastodon.org/entities/FilterResult/ + """ + filter: Filter + keyword_matches: Optional[List[str]] + status_matches: Optional[str] + + +@dataclass +class Status: + """ + https://docs.joinmastodon.org/entities/Status/ + """ + id: str + uri: str + created_at: datetime + account: Account + content: str + visibility: str + sensitive: bool + spoiler_text: str + media_attachments: List[MediaAttachment] + application: Optional[Application] + mentions: List[StatusMention] + tags: List[StatusTag] + emojis: List[CustomEmoji] + reblogs_count: int + favourites_count: int + replies_count: int + url: Optional[str] + in_reply_to_id: Optional[str] + in_reply_to_account_id: Optional[str] + reblog: Optional["Status"] + poll: Optional[Poll] + card: Optional[PreviewCard] + language: Optional[str] + text: Optional[str] + edited_at: Optional[datetime] + favourited: Optional[bool] + reblogged: Optional[bool] + muted: Optional[bool] + bookmarked: Optional[bool] + pinned: Optional[bool] + filtered: Optional[List[FilterResult]] + + @property + def original(self) -> "Status": + return self.reblog or self + + +# Generic data class instance +T = TypeVar("T") + + +def from_dict(cls: Type[T], data: Dict) -> T: + """Convert a nested dict into an instance of `cls`.""" + def _fields(): + hints = get_type_hints(cls) + for field in dataclasses.fields(cls): + field_type = _prune_optional(hints[field.name]) + default_value = _get_default_value(field) + value = data.get(field.name, default_value) + yield field.name, _convert(field_type, value) + + return cls(**dict(_fields())) + + +def _get_default_value(field): + if field.default is not dataclasses.MISSING: + return field.default + + if field.default_factory is not dataclasses.MISSING: + return field.default_factory() + + return None + + +def _convert(field_type, value): + if value is None: + return None + + if field_type in [str, int, bool, dict]: + return value + + if field_type == datetime: + return datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%f%z") + + if field_type == date: + return date.fromisoformat(value) + + if get_origin(field_type) == list: + (inner_type,) = get_args(field_type) + return [_convert(inner_type, x) for x in value] + + if is_dataclass(field_type): + return from_dict(field_type, value) + + raise ValueError(f"Not implemented for type '{field_type}'") + + +def _prune_optional(field_type): + """For `Optional[]` returns the encapsulated ``.""" + if get_origin(field_type) == Union: + args = get_args(field_type) + if len(args) == 2 and args[1] == type(None): + return args[0] + + return field_type