add media attachment editing

pull/397/head
halcy 2025-02-15 16:26:18 +02:00
rodzic ea87feade0
commit f7baffc085
11 zmienionych plików z 36006 dodań i 35 usunięć

Wyświetl plik

@ -47,6 +47,7 @@ Writing
.. automethod:: Mastodon.status_delete .. automethod:: Mastodon.status_delete
.. _status_update(): .. _status_update():
.. automethod:: Mastodon.status_update .. automethod:: Mastodon.status_update
.. automethod:: Mastodon.generate_media_edit_attributes
Scheduled statuses Scheduled statuses
------------------ ------------------

Wyświetl plik

@ -10,6 +10,7 @@ This function allows you to get information about a user's notifications as well
Reading Reading
~~~~~~~ ~~~~~~~
.. automethod:: Mastodon.notifications .. automethod:: Mastodon.notifications
.. automethod:: Mastodon.notifications_unread_count
Writing Writing
~~~~~~~ ~~~~~~~

Wyświetl plik

@ -6,7 +6,7 @@ from mastodon.utility import api_version
from mastodon.internals import Mastodon as Internals from mastodon.internals import Mastodon as Internals
from typing import Optional, List, Union from typing import Optional, List, Union
from mastodon.return_types import IdType, PrimitiveIdType, Account, AdminAccount, AdminReport, PaginatableList, NonPaginatableList, Status, Tag,\ from mastodon.return_types import IdType, PrimitiveIdType, Account, AdminAccount, AdminReport, PaginatableList, NonPaginatableList, Status, Tag,\
PreviewCard, AdminDomainBlock, AdminMeasure, AdminDimension, AdminRetention, AdminCanonicalEmailBlock PreviewCard, AdminDomainBlock, AdminMeasure, AdminDimension, AdminRetention, AdminCanonicalEmailBlock, AdminDomainAllow
from datetime import datetime from datetime import datetime
class Mastodon(Internals): class Mastodon(Internals):
@ -609,3 +609,52 @@ class Mastodon(Internals):
""" """
id = self.__unpack_id(id) id = self.__unpack_id(id)
return self.__api_request('DELETE', f'/api/v1/admin/canonical_email_blocks/{id}') return self.__api_request('DELETE', f'/api/v1/admin/canonical_email_blocks/{id}')
@api_version("4.0.0", "4.0.0")
def admin_domain_allows(self, max_id: Optional[IdType] = None, min_id: Optional[IdType] = None,
since_id: Optional[IdType] = None, limit: Optional[int] = None) -> PaginatableList[AdminDomainAllow]:
"""
Fetches a list of allowed domains. Requires scope `admin:read:domain_allows`.
The returned list may be paginated using max_id, min_id, and since_id.
NB: Untested, since I don't have a Mastodon instance in allowlist mode to test this with.
"""
params = self.__generate_params(locals())
return self.__api_request('GET', '/api/v1/admin/domain_allows', params)
@api_version("4.0.0", "4.0.0")
def admin_domain_allow(self, id: Union[AdminDomainAllow, IdType]) -> AdminDomainAllow:
"""
Fetch a single allowed domain by ID. Requires scope `admin:read:domain_allows`.
Raises `MastodonAPIError` if the domain allow does not exist.
NB: Untested, since I don't have a Mastodon instance in allowlist mode to test this with.
"""
id = self.__unpack_id(id)
return self.__api_request('GET', f'/api/v1/admin/domain_allows/{id}')
@api_version("4.0.0", "4.0.0")
def admin_create_domain_allow(self, domain: str) -> AdminDomainAllow:
"""
Allow a domain for federation. Requires scope `admin:write:domain_allows`.
If the domain is already allowed, returns the existing record.
NB: Untested, since I don't have a Mastodon instance in allowlist mode to test this with.
"""
params = {"domain": domain}
return self.__api_request('POST', '/api/v1/admin/domain_allows', params)
@api_version("4.0.0", "4.0.0")
def admin_delete_domain_allow(self, id: Union[AdminDomainAllow, IdType]):
"""
Remove a domain from the allowlist. Requires scope `admin:write:domain_allows`.
Raises `MastodonAPIError` if the domain allow does not exist.
NB: Untested, since I don't have a Mastodon instance in allowlist mode to test this with.
"""
id = self.__unpack_id(id)
self.__api_request('DELETE', f'/api/v1/admin/domain_allows/{id}')

Wyświetl plik

@ -158,6 +158,10 @@ class Mastodon():
kwargs['data'] = params kwargs['data'] = params
response_object = self.session.request(method, base_url + endpoint, **kwargs) response_object = self.session.request(method, base_url + endpoint, **kwargs)
if self.debug_requests:
print(f'Mastodon: Request URL: {response_object.request.url}')
print(f'Mastodon: Request body: {response_object.request.body}')
print(f'Mastodon: Response body: {response_object.text}')
except Exception as e: except Exception as e:
raise MastodonNetworkError(f"Could not complete request: {e}") raise MastodonNetworkError(f"Could not complete request: {e}")
@ -480,7 +484,7 @@ class Mastodon():
with closing(connection) as r: with closing(connection) as r:
listener.handle_stream(r) listener.handle_stream(r)
def __generate_params(self, params, exclude=[], dateconv=False): def __generate_params(self, params, exclude=[], dateconv=False, for_json=False):
""" """
Internal named-parameters-to-dict helper. Internal named-parameters-to-dict helper.
@ -503,6 +507,7 @@ class Mastodon():
if params[key] is None or key in exclude: if params[key] is None or key in exclude:
del params[key] del params[key]
if not for_json:
param_keys = list(params.keys()) param_keys = list(params.keys())
for key in param_keys: for key in param_keys:
if isinstance(params[key], list): if isinstance(params[key], list):

Wyświetl plik

@ -100,6 +100,10 @@ class Mastodon(Internals):
`focus` and `thumbnail` are as in :ref:`media_post() <media_post()>` . `focus` and `thumbnail` are as in :ref:`media_post() <media_post()>` .
The returned dict reflects the updates to the media attachment. The returned dict reflects the updates to the media attachment.
Note: This is for updating the metadata of an *unattached* media attachment (i.e. one that has
not been used in a status yet). For editing media attachment metadata after posting, see `status_update` and
`generate_media_edit_attributes`.
""" """
id = self.__unpack_id(id) id = self.__unpack_id(id)

Wyświetl plik

@ -29,7 +29,8 @@ class Mastodon(Internals):
* `status` - A user that the logged in user has enabned notifications for has enabled `notify` (see :ref:`account_follow() <account_follow()>`) * `status` - A user that the logged in user has enabned notifications for has enabled `notify` (see :ref:`account_follow() <account_follow()>`)
* `admin.sign_up` - For accounts with appropriate permissions: A new user has signed up * `admin.sign_up` - For accounts with appropriate permissions: A new user has signed up
* `admin.report` - For accounts with appropriate permissions: A new report has been received * `admin.report` - For accounts with appropriate permissions: A new report has been received
* TODO: document the rest * `severed_relationships` - Some of the logged in users relationships have been severed due to a moderation action on this server
* `moderation_warning` - The logged in user has been warned by a moderator
Parameters `exclude_types` and `types` are array of these types, specifying them will in- or exclude the Parameters `exclude_types` and `types` are array of these types, specifying them will in- or exclude the
types of notifications given. It is legal to give both parameters at the same tine, the result will then types of notifications given. It is legal to give both parameters at the same tine, the result will then
be the intersection of the results of both filters. Specifying `mentions_only` is a deprecated way to set be the intersection of the results of both filters. Specifying `mentions_only` is a deprecated way to set

Wyświetl plik

@ -2,15 +2,16 @@
import collections import collections
from datetime import datetime from datetime import datetime
import base64
from mastodon.errors import MastodonIllegalArgumentError from mastodon.errors import MastodonIllegalArgumentError, MastodonVersionError
from mastodon.utility import api_version from mastodon.utility import api_version
from mastodon.internals import Mastodon as Internals from mastodon.internals import Mastodon as Internals
from mastodon.return_types import Status, IdType, ScheduledStatus, PreviewCard, Context, NonPaginatableList, Account,\ from mastodon.return_types import Status, IdType, ScheduledStatus, PreviewCard, Context, NonPaginatableList, Account,\
MediaAttachment, Poll, StatusSource, StatusEdit, PaginatableList MediaAttachment, Poll, StatusSource, StatusEdit, PaginatableList, PathOrFile
from typing import Union, Optional, List from typing import Union, Optional, List, Dict, Any, Tuple
class Mastodon(Internals): class Mastodon(Internals):
### ###
@ -112,12 +113,11 @@ class Mastodon(Internals):
### ###
# Writing data: Statuses # Writing data: Statuses
### ###
def __status_internal(self, status: Optional[str], in_reply_to_id: Optional[Union[Status, IdType]] = None, media_ids: Optional[List[Union[MediaAttachment, IdType]]] = None, def __status_internal(self, status: Optional[str], in_reply_to_id: Optional[Union[Status, IdType]] = None, media_ids: Optional[List[Union[MediaAttachment, IdType]]] = None,
sensitive: Optional[bool] = False, visibility: Optional[str] = None, spoiler_text: Optional[str] = None, language: Optional[str] = None, sensitive: Optional[bool] = False, visibility: Optional[str] = None, spoiler_text: Optional[str] = None, language: Optional[str] = None,
idempotency_key: Optional[str] = None, content_type: Optional[str] = None, scheduled_at: Optional[datetime] = None, idempotency_key: Optional[str] = None, content_type: Optional[str] = None, scheduled_at: Optional[datetime] = None,
poll: Optional[Union[Poll, IdType]] = None, quote_id: Optional[Union[Status, IdType]] = None, edit: bool = False, poll: Optional[Union[Poll, IdType]] = None, quote_id: Optional[Union[Status, IdType]] = None, edit: bool = False,
strict_content_type: bool = False) -> Union[Status, ScheduledStatus]: strict_content_type: bool = False, media_attributes: Optional[List[Dict[str, Any]]] = None) -> Union[Status, ScheduledStatus]:
""" """
Internal statuses poster helper Internal statuses poster helper
""" """
@ -185,10 +185,17 @@ class Mastodon(Internals):
del params_initial['content_type'] del params_initial['content_type']
use_json = False use_json = False
if poll is not None: if poll is not None or media_attributes is not None:
use_json = True use_json = True
params = self.__generate_params(params_initial, ['idempotency_key', 'edit', 'strict_content_type']) # If media_attributes is set, make sure that media_ids contains at least all the IDs of the media from media_attributes
if media_attributes is not None:
if "media_ids" in params_initial and params_initial["media_ids"] is not None:
params_initial["media_ids"] = list(set(params_initial["media_ids"]) + set([x["id"] for x in media_attributes]))
else:
params_initial["media_ids"] = [x["id"] for x in media_attributes]
params = self.__generate_params(params_initial, ['idempotency_key', 'edit', 'strict_content_type'], for_json = use_json)
cast_type = Status cast_type = Status
if scheduled_at is not None: if scheduled_at is not None:
cast_type = ScheduledStatus cast_type = ScheduledStatus
@ -290,17 +297,49 @@ class Mastodon(Internals):
""" """
return self.status_post(status) return self.status_post(status)
@api_version("3.5.0", "3.5.0")
def status_update(self, id: Union[Status, IdType], status: Optional[str] = None, spoiler_text: Optional[str] = None, def generate_media_edit_attributes(self, id: Union[MediaAttachment, IdType], description: Optional[str] = None,
focus: Optional[Tuple[float, float]] = None,
thumbnail: Optional[PathOrFile] = None, thumb_mimetype: Optional[str] = None) -> Dict[str, Any]:
"""
Helper function to generate a single media edit attribute dictionary.
Parameters:
- `id` (str): The ID of the media attachment (mandatory).
- `description` (Optional[str]): A new description for the media.
- `focus` (Optional[Tuple[float, float]]): The focal point of the media.
- `thumbnail` (Optional[PathOrFile]): The thumbnail to be used.
"""
media_edit = {"id": self.__unpack_id(id)}
if description is not None:
media_edit["description"] = description
if focus is not None:
if isinstance(focus, tuple) and len(focus) == 2:
media_edit["focus"] = f"{focus[0]},{focus[1]}"
else:
raise MastodonIllegalArgumentError("Focus must be a tuple of two floats between -1 and 1")
if thumbnail is not None:
if not self.verify_minimum_version("3.2.0", cached=True):
raise MastodonVersionError('Thumbnail requires version > 3.2.0')
_, thumb_file, thumb_mimetype = self.__load_media_file(thumbnail, thumb_mimetype)
media_edit["thumbnail"] = f"data:{thumb_mimetype};base64,{base64.b64encode(thumb_file.read()).decode()}"
return media_edit
@api_version("3.5.0", "4.1.0")
def status_update(self, id: Union[Status, IdType], status: str, spoiler_text: Optional[str] = None,
sensitive: Optional[bool] = None, media_ids: Optional[List[Union[MediaAttachment, IdType]]] = None, sensitive: Optional[bool] = None, media_ids: Optional[List[Union[MediaAttachment, IdType]]] = None,
poll: Optional[Union[Poll, IdType]] = None) -> Status: poll: Optional[Union[Poll, IdType]] = None, media_attributes: Optional[List[Dict[str, Any]]] = None) -> Status:
""" """
Edit a status. The meanings of the fields are largely the same as in :ref:`status_post() <status_post()>`, Edit a status. The meanings of the fields are largely the same as in :ref:`status_post() <status_post()>`,
though not every field can be edited. though not every field can be edited. The `status` parameter is mandatory.
Note that editing a poll will reset the votes. Note that editing a poll will reset the votes.
TODO: Currently doesn't support editing media descriptions, implement that. To edit media metadata, generate a list of dictionaries with the following keys:
""" """
return self.__status_internal( return self.__status_internal(
status=status, status=status,
@ -308,7 +347,8 @@ class Mastodon(Internals):
sensitive=sensitive, sensitive=sensitive,
spoiler_text=spoiler_text, spoiler_text=spoiler_text,
poll=poll, poll=poll,
edit=id edit=id,
media_attributes=media_attributes
) )
@api_version("3.5.0", "3.5.0") @api_version("3.5.0", "3.5.0")

Wyświetl plik

@ -44,8 +44,9 @@ class StreamListener(object):
pass pass
def on_notification(self, notification): def on_notification(self, notification):
"""A new notification. `notification` is the parsed `notification dict` """A new notification. `notification` is the object
describing the notification.""" describing the notification. For more information, see the documentation
for the notifications() method."""
pass pass
def on_filters_changed(self): def on_filters_changed(self):
@ -106,7 +107,7 @@ class StreamListener(object):
def on_any_event(self, name, data): def on_any_event(self, name, data):
"""A generic event handler that is called for every event received. """A generic event handler that is called for every event received.
The name contains the event-name and data contains the content of the event. The name contains the event name and data contains the content of the event.
Called before the more specific on_xxx handlers. Called before the more specific on_xxx handlers.
""" """

Wyświetl plik

@ -8022,7 +8022,7 @@
{ {
"name": "Admin email block", "name": "Admin email block",
"python_name": "AdminCanonicalEmailBlock", "python_name": "AdminCanonicalEmailBlock",
"func_call": "api2.admin_create_canonical_email_block(email=<some email>)", "func_call": "mastodon.admin_create_canonical_email_block(email=<some email>)",
"func_call_real": null, "func_call_real": null,
"func_call_additional": null, "func_call_additional": null,
"func_alternate_acc": null, "func_alternate_acc": null,
@ -8065,11 +8065,11 @@
{ {
"name": "Admin domain allow", "name": "Admin domain allow",
"python_name": "AdminDomainAllow", "python_name": "AdminDomainAllow",
"func_call": "TODO_TO_BE_IMPLEMENTED", "func_call": "mastodon.admin_domain_allows()[0]",
"func_call_real": null, "func_call_real": null,
"func_call_additional": null, "func_call_additional": null,
"func_alternate_acc": null, "func_alternate_acc": null,
"manual_update": false, "manual_update": true,
"masto_doc_link": "https://docs.joinmastodon.org/entities/Admin_DomainAllow", "masto_doc_link": "https://docs.joinmastodon.org/entities/Admin_DomainAllow",
"description": "The opposite of a domain block, specifically allowing a domain to federate when the instance is in allowlist mode.", "description": "The opposite of a domain block, specifically allowing a domain to federate when the instance is in allowlist mode.",
"fields": { "fields": {
@ -9178,11 +9178,11 @@
{ {
"name": "Relationship Severance Event", "name": "Relationship Severance Event",
"python_name": "RelationshipSeveranceEvent", "python_name": "RelationshipSeveranceEvent",
"func_call": "TODO_TO_BE_IMPLEMENTED", "func_call": "# There isn't really a good way to get this manually - you get it if a moderation takes action.",
"func_call_real": null, "func_call_real": null,
"func_call_additional": null, "func_call_additional": null,
"func_alternate_acc": null, "func_alternate_acc": null,
"manual_update": false, "manual_update": true,
"masto_doc_link": "https://docs.joinmastodon.org/entities/RelationshipSeveranceEvent", "masto_doc_link": "https://docs.joinmastodon.org/entities/RelationshipSeveranceEvent",
"description": "Summary of a moderation or block event that caused follow relationships to be severed.", "description": "Summary of a moderation or block event that caused follow relationships to be severed.",
"fields": { "fields": {
@ -9206,7 +9206,7 @@
"version_history": [["4.3.0", "added"]], "version_history": [["4.3.0", "added"]],
"field_type": "str", "field_type": "str",
"field_subtype": null, "field_subtype": null,
"field_structuretype": null, "field_structuretype": "RelationshipSeveranceEventType",
"is_optional": false, "is_optional": false,
"is_nullable": false "is_nullable": false
}, },
@ -9547,7 +9547,7 @@
{ {
"name": "Account Warning", "name": "Account Warning",
"python_name": "AccountWarning", "python_name": "AccountWarning",
"func_call": "TODO_TO_BE_IMPLEMENTED", "func_call": "# There isn't really a good way to get this manually - you get it if a moderation takes action.",
"func_call_real": null, "func_call_real": null,
"func_call_additional": null, "func_call_additional": null,
"func_alternate_acc": null, "func_alternate_acc": null,
@ -9643,7 +9643,7 @@
"func_call_additional": null, "func_call_additional": null,
"func_alternate_acc": null, "func_alternate_acc": null,
"manual_update": false, "manual_update": false,
"masto_doc_link": "https://docs.joinmastodon.org/methods/notifications/#unread_count", "masto_doc_link": "https://docs.joinmastodon.org/methods/notifications/#unread-count",
"description": "Get the (capped) number of unread notifications for the current user.", "description": "Get the (capped) number of unread notifications for the current user.",
"fields": { "fields": {
"count": { "count": {

File diff suppressed because one or more lines are too long

Wyświetl plik

@ -241,3 +241,51 @@ def test_status_edit(api3, api2):
source = api2.status_source(status) source = api2.status_source(status)
assert source.text == "the best editor? why, of course it is the KDE Advanced Text Editor, Kate" assert source.text == "the best editor? why, of course it is the KDE Advanced Text Editor, Kate"
@pytest.mark.vcr(match_on=['path'])
def test_status_update_with_media_edit(api2):
media = api2.media_post(
'tests/video.mp4',
description="Original description",
focus=(-0.5, 0.3),
thumbnail='tests/amewatson.jpg'
)
assert media
assert media.url is None
time.sleep(5)
media2 = api2.media(media)
assert media2.id == media.id
assert media2.url is not None
status = api2.status_post(
'Initial post with media',
media_ids=media2
)
assert status
assert status['media_attachments'][0]['description'] == "Original description"
assert status['media_attachments'][0]['meta']['focus']['x'] == -0.5
assert status['media_attachments'][0]['meta']['focus']['y'] == 0.3
try:
updated_media_attributes = api2.generate_media_edit_attributes(
id=media2.id,
description="Updated description",
focus=(0.2, -0.1),
thumbnail='tests/image.jpg'
)
updated_status = api2.status_update(
status['id'],
"I have altered the media attachment. Pray I do not alter it further.",
media_attributes=[updated_media_attributes]
)
assert updated_status
assert updated_status['media_attachments'][0]['description'] == "Updated description"
assert updated_status['media_attachments'][0]['meta']['focus']['x'] == 0.2
assert updated_status['media_attachments'][0]['meta']['focus']['y'] == -0.1
assert updated_status['media_attachments'][0]['preview_url'] != status['media_attachments'][0]['preview_url']
finally:
api2.status_delete(status['id'])