mirror of
https://github.com/ihabunek/toot
synced 2024-12-22 23:08:17 +01:00
567 lines
13 KiB
Python
567 lines
13 KiB
Python
"""
|
|
Dataclasses which represent entities returned by the Mastodon API.
|
|
|
|
Data classes my have an optional static method named `__toot_prepare__` which is
|
|
used when constructing the data class using `from_dict`. The method will be
|
|
called with the dict and may modify it and return a modified dict. This is used
|
|
to implement any pre-processing which may be required, e.g. to support
|
|
different versions of the Mastodon API.
|
|
"""
|
|
|
|
import dataclasses
|
|
import typing as t
|
|
|
|
from dataclasses import dataclass, is_dataclass
|
|
from datetime import date, datetime
|
|
from functools import lru_cache
|
|
from typing import Any, Dict, NamedTuple, Optional, Type, TypeVar, Union
|
|
from typing import get_args, get_origin, get_type_hints
|
|
|
|
from toot.utils import get_text
|
|
from toot.utils.datetime import parse_datetime
|
|
|
|
# Generic data class instance
|
|
T = TypeVar("T")
|
|
|
|
# A dict decoded from JSON
|
|
Data = Dict[str, Any]
|
|
|
|
|
|
@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: t.List[AccountField]
|
|
emojis: t.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
|
|
source: Optional[dict]
|
|
|
|
@staticmethod
|
|
def __toot_prepare__(obj: Data) -> Data:
|
|
# Pleroma has not yet converted last_status_at from datetime to date
|
|
# so trim it here so it doesn't break when converting to date.
|
|
# See: https://git.pleroma.social/pleroma/pleroma/-/issues/1470
|
|
last_status_at = obj.get("last_status_at")
|
|
if last_status_at:
|
|
obj.update(last_status_at=obj["last_status_at"][:10])
|
|
return obj
|
|
|
|
@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: t.List[PollOption]
|
|
emojis: t.List[CustomEmoji]
|
|
voted: Optional[bool]
|
|
own_votes: Optional[t.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: t.List[str]
|
|
expires_at: Optional[datetime]
|
|
filter_action: str
|
|
keywords: t.List[FilterKeyword]
|
|
statuses: t.List[FilterStatus]
|
|
|
|
|
|
@dataclass
|
|
class FilterResult:
|
|
"""
|
|
https://docs.joinmastodon.org/entities/FilterResult/
|
|
"""
|
|
filter: Filter
|
|
keyword_matches: Optional[t.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: t.List[MediaAttachment]
|
|
application: Optional[Application]
|
|
mentions: t.List[StatusMention]
|
|
tags: t.List[StatusTag]
|
|
emojis: t.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[t.List[FilterResult]]
|
|
|
|
@property
|
|
def original(self) -> "Status":
|
|
return self.reblog or self
|
|
|
|
@staticmethod
|
|
def __toot_prepare__(obj: Data) -> Data:
|
|
# Pleroma has a bug where created_at is set to an empty string.
|
|
# To avoid marking created_at as optional, which would require work
|
|
# because we count on it always existing, set it to current datetime.
|
|
# Possible underlying issue:
|
|
# https://git.pleroma.social/pleroma/pleroma/-/issues/2851
|
|
if not obj["created_at"]:
|
|
obj["created_at"] = datetime.now().astimezone().isoformat()
|
|
return obj
|
|
|
|
|
|
@dataclass
|
|
class Report:
|
|
"""
|
|
https://docs.joinmastodon.org/entities/Report/
|
|
"""
|
|
id: str
|
|
action_taken: bool
|
|
action_taken_at: Optional[datetime]
|
|
category: str
|
|
comment: str
|
|
forwarded: bool
|
|
created_at: datetime
|
|
status_ids: Optional[t.List[str]]
|
|
rule_ids: Optional[t.List[str]]
|
|
target_account: Account
|
|
|
|
|
|
@dataclass
|
|
class Notification:
|
|
"""
|
|
https://docs.joinmastodon.org/entities/Notification/
|
|
"""
|
|
id: str
|
|
type: str
|
|
created_at: datetime
|
|
account: Account
|
|
status: Optional[Status]
|
|
report: Optional[Report]
|
|
|
|
|
|
@dataclass
|
|
class InstanceUrls:
|
|
streaming_api: str
|
|
|
|
|
|
@dataclass
|
|
class InstanceStats:
|
|
user_count: int
|
|
status_count: int
|
|
domain_count: int
|
|
|
|
|
|
@dataclass
|
|
class InstanceConfigurationStatuses:
|
|
max_characters: int
|
|
max_media_attachments: int
|
|
characters_reserved_per_url: int
|
|
|
|
|
|
@dataclass
|
|
class InstanceConfigurationMediaAttachments:
|
|
supported_mime_types: t.List[str]
|
|
image_size_limit: int
|
|
image_matrix_limit: int
|
|
video_size_limit: int
|
|
video_frame_rate_limit: int
|
|
video_matrix_limit: int
|
|
|
|
|
|
@dataclass
|
|
class InstanceConfigurationPolls:
|
|
max_options: int
|
|
max_characters_per_option: int
|
|
min_expiration: int
|
|
max_expiration: int
|
|
|
|
|
|
@dataclass
|
|
class InstanceConfiguration:
|
|
"""
|
|
https://docs.joinmastodon.org/entities/V1_Instance/#configuration
|
|
"""
|
|
statuses: InstanceConfigurationStatuses
|
|
media_attachments: InstanceConfigurationMediaAttachments
|
|
polls: InstanceConfigurationPolls
|
|
|
|
|
|
@dataclass
|
|
class Rule:
|
|
"""
|
|
https://docs.joinmastodon.org/entities/Rule/
|
|
"""
|
|
id: str
|
|
text: str
|
|
|
|
|
|
@dataclass
|
|
class Instance:
|
|
"""
|
|
https://docs.joinmastodon.org/entities/V1_Instance/
|
|
"""
|
|
uri: str
|
|
title: str
|
|
short_description: str
|
|
description: str
|
|
email: str
|
|
version: str
|
|
urls: InstanceUrls
|
|
stats: InstanceStats
|
|
thumbnail: Optional[str]
|
|
languages: t.List[str]
|
|
registrations: bool
|
|
approval_required: bool
|
|
invites_enabled: bool
|
|
configuration: InstanceConfiguration
|
|
contact_account: Optional[Account]
|
|
rules: t.List[Rule]
|
|
|
|
|
|
@dataclass
|
|
class Relationship:
|
|
"""
|
|
Represents the relationship between accounts, such as following / blocking /
|
|
muting / etc.
|
|
https://docs.joinmastodon.org/entities/Relationship/
|
|
"""
|
|
id: str
|
|
following: bool
|
|
showing_reblogs: bool
|
|
notifying: bool
|
|
languages: t.List[str]
|
|
followed_by: bool
|
|
blocking: bool
|
|
blocked_by: bool
|
|
muting: bool
|
|
muting_notifications: bool
|
|
requested: bool
|
|
domain_blocking: bool
|
|
endorsed: bool
|
|
note: str
|
|
|
|
|
|
@dataclass
|
|
class TagHistory:
|
|
"""
|
|
Usage statistics for given days (typically the past week).
|
|
https://docs.joinmastodon.org/entities/Tag/#history
|
|
"""
|
|
day: str
|
|
uses: str
|
|
accounts: str
|
|
|
|
|
|
@dataclass
|
|
class Tag:
|
|
"""
|
|
Represents a hashtag used within the content of a status.
|
|
https://docs.joinmastodon.org/entities/Tag/
|
|
"""
|
|
name: str
|
|
url: str
|
|
history: t.List[TagHistory]
|
|
following: Optional[bool]
|
|
|
|
|
|
@dataclass
|
|
class FeaturedTag:
|
|
"""
|
|
Represents a hashtag that is featured on a profile.
|
|
https://docs.joinmastodon.org/entities/FeaturedTag/
|
|
"""
|
|
id: str
|
|
name: str
|
|
url: str
|
|
statuses_count: int
|
|
last_status_at: datetime
|
|
|
|
|
|
@dataclass
|
|
class List:
|
|
"""
|
|
Represents a list of some users that the authenticated user follows.
|
|
https://docs.joinmastodon.org/entities/List/
|
|
"""
|
|
id: str
|
|
title: str
|
|
# This is a required field on Mastodon, but not supported on Pleroma/Akkoma
|
|
# see: https://git.pleroma.social/pleroma/pleroma/-/issues/2918
|
|
replies_policy: Optional[str]
|
|
|
|
# ------------------------------------------------------------------------------
|
|
|
|
|
|
class Field(NamedTuple):
|
|
name: str
|
|
type: Any
|
|
default: Any
|
|
|
|
|
|
class ConversionError(Exception):
|
|
"""Raised when conversion fails from JSON value to data class field."""
|
|
def __init__(self, data_class: type, field: Field, field_value: Optional[str]):
|
|
super().__init__(
|
|
f"Failed converting field `{data_class.__name__}.{field.name}` "
|
|
+ f"of type `{field.type.__name__}` from value {field_value!r}"
|
|
)
|
|
|
|
|
|
def from_dict(cls: Type[T], data: Data) -> T:
|
|
"""Convert a nested dict into an instance of `cls`."""
|
|
# Apply __toot_prepare__ if it exists
|
|
prepare = getattr(cls, '__toot_prepare__', None)
|
|
if prepare:
|
|
data = prepare(data)
|
|
|
|
def _fields():
|
|
for field in _get_fields(cls):
|
|
value = data.get(field.name, field.default)
|
|
converted = _convert_with_error_handling(cls, field, value)
|
|
yield field.name, converted
|
|
|
|
return cls(**dict(_fields()))
|
|
|
|
|
|
@lru_cache
|
|
def _get_fields(cls: type) -> t.List[Field]:
|
|
hints = get_type_hints(cls)
|
|
return [
|
|
Field(
|
|
field.name,
|
|
_prune_optional(hints[field.name]),
|
|
_get_default_value(field)
|
|
)
|
|
for field in dataclasses.fields(cls)
|
|
]
|
|
|
|
|
|
def from_dict_list(cls: Type[T], data: t.List[Data]) -> t.List[T]:
|
|
return [from_dict(cls, x) for x in data]
|
|
|
|
|
|
def _get_default_value(field: dataclasses.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_with_error_handling(data_class: type, field: Field, field_value: Any) -> Any:
|
|
try:
|
|
return _convert(field.type, field_value)
|
|
except ConversionError:
|
|
raise
|
|
except Exception:
|
|
raise ConversionError(data_class, field, field_value)
|
|
|
|
|
|
def _convert(field_type: Any, value: Any) -> Any:
|
|
if value is None:
|
|
return None
|
|
|
|
if field_type in [str, int, bool, dict]:
|
|
return value
|
|
|
|
if field_type == datetime:
|
|
return parse_datetime(value)
|
|
|
|
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: type) -> type:
|
|
"""For `Optional[<type>]` returns the encapsulated `<type>`."""
|
|
if get_origin(field_type) == Union:
|
|
args = get_args(field_type)
|
|
if len(args) == 2 and args[1] == type(None): # noqa
|
|
return args[0]
|
|
|
|
return field_type
|