toot/toot/entities.py

567 wiersze
13 KiB
Python

2023-06-26 13:21:26 +00:00
"""
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.
2023-06-26 13:21:26 +00:00
"""
import dataclasses
2024-03-09 08:32:04 +00:00
import typing as t
2023-06-26 13:21:26 +00:00
from dataclasses import dataclass, is_dataclass
from datetime import date, datetime
from functools import lru_cache
2024-04-13 13:30:52 +00:00
from typing import Any, Dict, NamedTuple, Optional, Type, TypeVar, Union
from typing import get_args, get_origin, get_type_hints
2023-06-26 13:21:26 +00:00
from toot.utils import get_text
2023-11-18 14:44:50 +00:00
from toot.utils.datetime import parse_datetime
2023-06-26 13:21:26 +00:00
2024-04-13 13:30:52 +00:00
# Generic data class instance
T = TypeVar("T")
# A dict decoded from JSON
Data = Dict[str, Any]
2023-06-26 13:21:26 +00:00
@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
2024-03-09 08:32:04 +00:00
fields: t.List[AccountField]
emojis: t.List[CustomEmoji]
2023-06-26 13:21:26 +00:00
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
2023-11-22 07:41:15 +00:00
source: Optional[dict]
2023-06-26 13:21:26 +00:00
@staticmethod
2024-04-13 13:30:52 +00:00
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
2023-06-26 13:21:26 +00:00
@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]
2024-03-09 08:32:04 +00:00
options: t.List[PollOption]
emojis: t.List[CustomEmoji]
2023-06-26 13:21:26 +00:00
voted: Optional[bool]
2024-03-09 08:32:04 +00:00
own_votes: Optional[t.List[int]]
2023-06-26 13:21:26 +00:00
@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
2024-03-09 08:32:04 +00:00
context: t.List[str]
2023-06-26 13:21:26 +00:00
expires_at: Optional[datetime]
filter_action: str
2024-03-09 08:32:04 +00:00
keywords: t.List[FilterKeyword]
statuses: t.List[FilterStatus]
2023-06-26 13:21:26 +00:00
@dataclass
class FilterResult:
"""
https://docs.joinmastodon.org/entities/FilterResult/
"""
filter: Filter
2024-03-09 08:32:04 +00:00
keyword_matches: Optional[t.List[str]]
2023-06-26 13:21:26 +00:00
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
2024-03-09 08:32:04 +00:00
media_attachments: t.List[MediaAttachment]
2023-06-26 13:21:26 +00:00
application: Optional[Application]
2024-03-09 08:32:04 +00:00
mentions: t.List[StatusMention]
tags: t.List[StatusTag]
emojis: t.List[CustomEmoji]
2023-06-26 13:21:26 +00:00
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]
2024-03-09 08:32:04 +00:00
filtered: Optional[t.List[FilterResult]]
2023-06-26 13:21:26 +00:00
@property
def original(self) -> "Status":
return self.reblog or self
2023-11-18 11:32:35 +00:00
@staticmethod
2024-04-13 13:30:52 +00:00
def __toot_prepare__(obj: Data) -> Data:
2023-11-18 11:32:35 +00:00
# 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
2023-06-26 13:21:26 +00:00
2023-06-26 13:50:30 +00:00
@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
2024-03-09 08:32:04 +00:00
status_ids: Optional[t.List[str]]
rule_ids: Optional[t.List[str]]
2023-06-26 13:50:30 +00:00
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]
2023-06-26 14:16:08 +00:00
@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:
2024-03-09 08:32:04 +00:00
supported_mime_types: t.List[str]
2023-06-26 14:16:08 +00:00
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]
2024-03-09 08:32:04 +00:00
languages: t.List[str]
2023-06-26 14:16:08 +00:00
registrations: bool
approval_required: bool
invites_enabled: bool
configuration: InstanceConfiguration
contact_account: Optional[Account]
2024-03-09 08:32:04 +00:00
rules: t.List[Rule]
2023-06-26 14:16:08 +00:00
2023-11-21 17:16:23 +00:00
@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
2024-03-09 08:32:04 +00:00
languages: t.List[str]
2023-11-21 17:16:23 +00:00
followed_by: bool
blocking: bool
blocked_by: bool
muting: bool
muting_notifications: bool
requested: bool
domain_blocking: bool
endorsed: bool
note: str
2023-12-12 08:45:57 +00:00
@dataclass
class TagHistory:
2023-12-13 06:50:45 +00:00
"""
Usage statistics for given days (typically the past week).
https://docs.joinmastodon.org/entities/Tag/#history
"""
2023-12-12 08:45:57 +00:00
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
2024-03-09 08:32:04 +00:00
history: t.List[TagHistory]
2023-12-12 08:45:57 +00:00
following: Optional[bool]
2023-12-13 06:50:45 +00:00
@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
2024-03-09 08:32:04 +00:00
@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]
2024-04-13 13:30:52 +00:00
# ------------------------------------------------------------------------------
2024-03-09 08:32:04 +00:00
2024-04-13 13:30:52 +00:00
class Field(NamedTuple):
name: str
type: Any
default: Any
2023-06-26 13:21:26 +00:00
2023-11-18 10:18:13 +00:00
class ConversionError(Exception):
"""Raised when conversion fails from JSON value to data class field."""
2024-04-13 13:30:52 +00:00
def __init__(self, data_class: type, field: Field, field_value: Optional[str]):
2023-11-18 10:18:13 +00:00
super().__init__(
2024-04-13 13:30:52 +00:00
f"Failed converting field `{data_class.__name__}.{field.name}` "
+ f"of type `{field.type.__name__}` from value {field_value!r}"
2023-11-18 10:18:13 +00:00
)
2024-04-13 13:30:52 +00:00
def from_dict(cls: Type[T], data: Data) -> T:
2023-06-26 13:21:26 +00:00
"""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)
2023-06-26 13:21:26 +00:00
def _fields():
2024-04-13 13:30:52 +00:00
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
2023-06-26 13:21:26 +00:00
return cls(**dict(_fields()))
2024-04-13 13:30:52 +00:00
@lru_cache
def _get_fields(cls: type) -> t.List[Field]:
hints = get_type_hints(cls)
return [
2024-04-13 13:30:52 +00:00
Field(
field.name,
_prune_optional(hints[field.name]),
_get_default_value(field)
)
for field in dataclasses.fields(cls)
]
2024-04-13 13:30:52 +00:00
def from_dict_list(cls: Type[T], data: t.List[Data]) -> t.List[T]:
2023-11-21 17:16:23 +00:00
return [from_dict(cls, x) for x in data]
2024-04-14 07:05:09 +00:00
def _get_default_value(field: dataclasses.Field):
2023-06-26 13:21:26 +00:00
if field.default is not dataclasses.MISSING:
return field.default
if field.default_factory is not dataclasses.MISSING:
return field.default_factory()
return None
2024-04-13 13:30:52 +00:00
def _convert_with_error_handling(data_class: type, field: Field, field_value: Any) -> Any:
2023-11-18 10:18:13 +00:00
try:
2024-04-13 13:30:52 +00:00
return _convert(field.type, field_value)
2023-11-18 10:18:13 +00:00
except ConversionError:
raise
except Exception:
2024-04-13 13:30:52 +00:00
raise ConversionError(data_class, field, field_value)
2023-11-18 10:18:13 +00:00
2024-04-13 13:30:52 +00:00
def _convert(field_type: Any, value: Any) -> Any:
2023-06-26 13:21:26 +00:00
if value is None:
return None
if field_type in [str, int, bool, dict]:
return value
if field_type == datetime:
2023-11-18 14:44:50 +00:00
return parse_datetime(value)
2023-06-26 13:21:26 +00:00
if field_type == date:
return date.fromisoformat(value)
2023-06-26 13:21:26 +00:00
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}'")
2024-04-13 13:30:52 +00:00
def _prune_optional(field_type: type) -> type:
2023-06-26 13:21:26 +00:00
"""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
2023-06-26 13:21:26 +00:00
return args[0]
return field_type