From fd12591ee246841c67203fa12d2fb8bf67e6ac73 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Tue, 14 Feb 2023 11:47:23 +0100 Subject: [PATCH] wip --- toot/tui/app.py | 24 +-- toot/tui/entities.py | 338 ++++++++++++++++++++++++++++++--------- toot/tui/old_entities.py | 90 +++++++++++ toot/tui/timeline.py | 2 +- 4 files changed, 370 insertions(+), 84 deletions(-) create mode 100644 toot/tui/old_entities.py diff --git a/toot/tui/app.py b/toot/tui/app.py index fbbf72a..d61078c 100644 --- a/toot/tui/app.py +++ b/toot/tui/app.py @@ -9,7 +9,7 @@ from toot.exceptions import ApiError from .compose import StatusComposer from .constants import PALETTE -from .entities import Status +from .entities import Status, from_dict from .overlays import ExceptionStackTrace, GotoMenu, Help, StatusSource, StatusLinks, StatusZoom from .overlays import StatusDeleteConfirmation, Account from .timeline import Timeline @@ -258,8 +258,9 @@ class TUI(urwid.Frame): return timeline def make_status(self, status_data): - is_mine = self.user.username == status_data["account"]["acct"] - return Status(status_data, is_mine, self.app.instance) + return from_dict(Status, status_data) + # is_mine = self.user.username == status_data["account"]["acct"] + # return Status(status_data, is_mine, self.app.instance) def show_thread(self, status): def _close(*args): @@ -379,14 +380,15 @@ class TUI(urwid.Frame): def clear_screen(self): self.loop.screen.clear() - def show_links(self, status): - links = parse_content_links(status.data["content"]) if status else [] - post_attachments = status.data["media_attachments"] or [] - reblog_attachments = (status.data["reblog"]["media_attachments"] if status.data["reblog"] else None) or [] + def show_links(self, status: Status): + links = parse_content_links(status.content) + post_attachments = status.media_attachments + reblog_attachments = status.reblog.media_attachments if status.reblog else [] - for a in post_attachments + reblog_attachments: - url = a["remote_url"] or a["url"] - links.append((url, a["description"] if a["description"] else url)) + for attachment in post_attachments + reblog_attachments: + url = attachment.remote_url or attachment.url + description = attachment.description if attachment.description else url + links.append((url, description)) def _clear(*args): self.clear_screen() @@ -536,7 +538,7 @@ class TUI(urwid.Frame): done_callback=_done ) - def async_toggle_reblog(self, timeline, status): + def async_toggle_reblog(self, timeline: Timeline, status: Status): def _reblog(): logger.info("Reblogging {}".format(status)) api.reblog(self.app, self.user, status.id, visibility=get_default_visibility()) diff --git a/toot/tui/entities.py b/toot/tui/entities.py index a30bcb6..7611b26 100644 --- a/toot/tui/entities.py +++ b/toot/tui/entities.py @@ -1,90 +1,284 @@ -from collections import namedtuple +import dataclasses -from .utils import parse_datetime +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 -Author = namedtuple("Author", ["account", "display_name", "username"]) +from toot.typing_compat import get_args, get_origin +from toot.utils import get_text -class Status: +@dataclass +class AccountField: """ - A wrapper around the Status entity data fetched from Mastodon. - - https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md#status - - Attributes - ---------- - reblog : Status or None - The reblogged status if it exists. - - original : Status - If a reblog, the reblogged status, otherwise self. + https://docs.joinmastodon.org/entities/Account/#Field """ + name: str + value: str + verified_at: Optional[datetime] - def __init__(self, data, is_mine, default_instance): - """ - Parameters - ---------- - data : dict - Status data as received from Mastodon. - https://docs.joinmastodon.org/api/entities/#status - is_mine : bool - Whether the status was created by the logged in user. +@dataclass +class CustomEmoji: + """ + https://docs.joinmastodon.org/entities/CustomEmoji/ + """ + shortcode: str + url: str + static_url: str + visible_in_picker: bool + category: str - default_instance : str - The domain of the instance into which the user is logged in. Used to - create fully qualified account names for users on the same instance. - Mastodon only populates the name, not the domain. - """ - self.data = data - self.is_mine = is_mine - self.default_instance = default_instance - - # This can be toggled by the user - self.show_sensitive = False - - # Set when status is translated - self.show_translation = False - self.translation = None - self.translated_from = None - - # TODO: clean up - self.id = self.data["id"] - self.account = self._get_account() - self.created_at = parse_datetime(data["created_at"]) - self.author = self._get_author() - self.favourited = data.get("favourited", False) - self.reblogged = data.get("reblogged", False) - self.bookmarked = data.get("bookmarked", False) - self.in_reply_to = data.get("in_reply_to_id") - self.url = data.get("url") - self.mentions = data.get("mentions") - self.reblog = self._get_reblog() - self.visibility = data.get("visibility") +@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 original(self): + def note_plaintext(self) -> str: + return get_text(self.note) + + +@dataclass +class Application: + name: str + website: Optional[str] + + +@dataclass +class 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: + 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: + id: str + keyword: str + whole_word: str + + +@dataclass +class FilterStatus: + id: str + status_id: str + + +@dataclass +class Filter: + id: str + title: str + context: List[str] + expires_at: Optional[datetime] + filter_action: str + keywords: List[FilterKeyword] + statuses: List[FilterStatus] + + +@dataclass +class FilterResult: + filter: Filter + keyword_matches: Optional[List[str]] + status_matches: Optional[str] + + +@dataclass +class 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 - def _get_reblog(self): - reblog = self.data.get("reblog") - if not reblog: - return None - reblog_is_mine = self.is_mine and ( - self.data["account"]["acct"] == reblog["account"]["acct"] - ) - return Status(reblog, reblog_is_mine, self.default_instance) +@dataclass +class InstanceV2: + domain: str + title: str + version: str + source_url: str + description: str + usage: dict # TODO expand + thumbnail: dict # TODO expand + languages: List[str] + configuration: dict # TODO expand + registrations: dict # TODO expand + contact: dict # TODO expand + rules: List[dict] # TODO expand - def _get_author(self): - acct = self.data['account']['acct'] - acct = acct if "@" in acct else "{}@{}".format(acct, self.default_instance) - return Author(acct, self.data['account']['display_name'], self.data['account']['username']) - def _get_account(self): - acct = self.data['account']['acct'] - return acct if "@" in acct else "{}@{}".format(acct, self.default_instance) +# Generic data class instance +T = TypeVar("T") - def __repr__(self): - return "".format(self.id, self.account) + +def from_dict(cls: Type[T], data: Dict) -> T: + def _fields(): + hints = get_type_hints(cls) + for field in dataclasses.fields(cls): + default = field.default if field.default is not dataclasses.MISSING else None + field_type = prune_optional(hints[field.name]) + value = data.get(field.name, default) + yield convert(field_type, value) + + return cls(*_fields()) + + +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[]` returnes 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 diff --git a/toot/tui/old_entities.py b/toot/tui/old_entities.py new file mode 100644 index 0000000..a30bcb6 --- /dev/null +++ b/toot/tui/old_entities.py @@ -0,0 +1,90 @@ +from collections import namedtuple + +from .utils import parse_datetime + +Author = namedtuple("Author", ["account", "display_name", "username"]) + + +class Status: + """ + A wrapper around the Status entity data fetched from Mastodon. + + https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md#status + + Attributes + ---------- + reblog : Status or None + The reblogged status if it exists. + + original : Status + If a reblog, the reblogged status, otherwise self. + """ + + def __init__(self, data, is_mine, default_instance): + """ + Parameters + ---------- + data : dict + Status data as received from Mastodon. + https://docs.joinmastodon.org/api/entities/#status + + is_mine : bool + Whether the status was created by the logged in user. + + default_instance : str + The domain of the instance into which the user is logged in. Used to + create fully qualified account names for users on the same instance. + Mastodon only populates the name, not the domain. + """ + + self.data = data + self.is_mine = is_mine + self.default_instance = default_instance + + # This can be toggled by the user + self.show_sensitive = False + + # Set when status is translated + self.show_translation = False + self.translation = None + self.translated_from = None + + # TODO: clean up + self.id = self.data["id"] + self.account = self._get_account() + self.created_at = parse_datetime(data["created_at"]) + self.author = self._get_author() + self.favourited = data.get("favourited", False) + self.reblogged = data.get("reblogged", False) + self.bookmarked = data.get("bookmarked", False) + self.in_reply_to = data.get("in_reply_to_id") + self.url = data.get("url") + self.mentions = data.get("mentions") + self.reblog = self._get_reblog() + self.visibility = data.get("visibility") + + @property + def original(self): + return self.reblog or self + + def _get_reblog(self): + reblog = self.data.get("reblog") + if not reblog: + return None + + reblog_is_mine = self.is_mine and ( + self.data["account"]["acct"] == reblog["account"]["acct"] + ) + return Status(reblog, reblog_is_mine, self.default_instance) + + def _get_author(self): + acct = self.data['account']['acct'] + acct = acct if "@" in acct else "{}@{}".format(acct, self.default_instance) + return Author(acct, self.data['account']['display_name'], self.data['account']['username']) + + def _get_account(self): + acct = self.data['account']['acct'] + return acct if "@" in acct else "{}@{}".format(acct, self.default_instance) + + def __repr__(self): + return "".format(self.id, self.account) diff --git a/toot/tui/timeline.py b/toot/tui/timeline.py index 41bc1f8..222418e 100644 --- a/toot/tui/timeline.py +++ b/toot/tui/timeline.py @@ -273,7 +273,7 @@ class Timeline(urwid.Columns): index = self.get_status_index(status.id) self.status_list.body.set_focus(index) - def update_status(self, status): + def update_status(self, status: Status): """Overwrite status in list with the new instance and redraw.""" index = self.get_status_index(status.id) assert self.statuses[index].id == status.id # Sanity check