diff --git a/tests/test_integration.py b/tests/test_integration.py index 49a7a78..1471960 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -35,6 +35,9 @@ HOSTNAME = os.getenv("TOOT_TEST_HOSTNAME") # Mastodon database name, used to confirm user registration without having to click the link DATABASE_DSN = os.getenv("TOOT_TEST_DATABASE_DSN") +# Toot logo used for testing image upload +TRUMPET = path.join(path.dirname(path.dirname(path.realpath(__file__))), "trumpet.png") + if not HOSTNAME or not DATABASE_DSN: pytest.skip("Skipping integration tests", allow_module_level=True) @@ -496,6 +499,125 @@ def test_tags(run): assert out == "* #bar\thttp://localhost:3000/tags/bar" +def test_update_account_no_options(run): + with pytest.raises(ConsoleError) as exc: + run("update_account") + assert str(exc.value) == "Please specify at least one option to update the account" + + +def test_update_account_display_name(run, app, user): + out = run("update_account", "--display-name", "elwood") + assert out == "✓ Account updated" + + account = api.verify_credentials(app, user) + assert account["display_name"] == "elwood" + + +def test_update_account_note(run, app, user): + note = ("It's 106 miles to Chicago, we got a full tank of gas, half a pack " + "of cigarettes, it's dark... and we're wearing sunglasses.") + + out = run("update_account", "--note", note) + assert out == "✓ Account updated" + + account = api.verify_credentials(app, user) + assert get_text(account["note"]) == note + + +def test_update_account_language(run, app, user): + out = run("update_account", "--language", "hr") + assert out == "✓ Account updated" + + account = api.verify_credentials(app, user) + assert account["source"]["language"] == "hr" + + +def test_update_account_privacy(run, app, user): + out = run("update_account", "--privacy", "private") + assert out == "✓ Account updated" + + account = api.verify_credentials(app, user) + assert account["source"]["privacy"] == "private" + + +def test_update_account_avatar(run, app, user): + account = api.verify_credentials(app, user) + old_value = account["avatar"] + + out = run("update_account", "--avatar", TRUMPET) + assert out == "✓ Account updated" + + account = api.verify_credentials(app, user) + assert account["avatar"] != old_value + + +def test_update_account_header(run, app, user): + account = api.verify_credentials(app, user) + old_value = account["header"] + + out = run("update_account", "--header", TRUMPET) + assert out == "✓ Account updated" + + account = api.verify_credentials(app, user) + assert account["header"] != old_value + + +def test_update_account_locked(run, app, user): + out = run("update_account", "--locked") + assert out == "✓ Account updated" + + account = api.verify_credentials(app, user) + assert account["locked"] is True + + out = run("update_account", "--no-locked") + assert out == "✓ Account updated" + + account = api.verify_credentials(app, user) + assert account["locked"] is False + + +def test_update_account_bot(run, app, user): + out = run("update_account", "--bot") + assert out == "✓ Account updated" + + account = api.verify_credentials(app, user) + assert account["bot"] is True + + out = run("update_account", "--no-bot") + assert out == "✓ Account updated" + + account = api.verify_credentials(app, user) + assert account["bot"] is False + + +def test_update_account_discoverable(run, app, user): + out = run("update_account", "--discoverable") + assert out == "✓ Account updated" + + account = api.verify_credentials(app, user) + assert account["discoverable"] is True + + out = run("update_account", "--no-discoverable") + assert out == "✓ Account updated" + + account = api.verify_credentials(app, user) + assert account["discoverable"] is False + + +def test_update_account_sensitive(run, app, user): + out = run("update_account", "--sensitive") + assert out == "✓ Account updated" + + account = api.verify_credentials(app, user) + assert account["source"]["sensitive"] is True + + out = run("update_account", "--no-sensitive") + assert out == "✓ Account updated" + + account = api.verify_credentials(app, user) + assert account["source"]["sensitive"] is False + + # ------------------------------------------------------------------------------ # Utils # ------------------------------------------------------------------------------ diff --git a/toot/api.py b/toot/api.py index a608609..3f10c37 100644 --- a/toot/api.py +++ b/toot/api.py @@ -1,12 +1,12 @@ import re -from typing import List import uuid +from typing import List from urllib.parse import urlparse, urlencode, quote from toot import http, CLIENT_NAME, CLIENT_WEBSITE from toot.exceptions import AuthenticationError -from toot.utils import str_bool +from toot.utils import str_bool, str_bool_nullable SCOPES = 'read write follow' @@ -67,6 +67,42 @@ def register_account(app, username, email, password, locale="en", agreement=True return http.anon_post(url, json=json, headers=headers).json() +def update_account( + app, + user, + display_name=None, + note=None, + avatar=None, + header=None, + bot=None, + discoverable=None, + locked=None, + privacy=None, + sensitive=None, + language=None +): + """ + Update account credentials + https://docs.joinmastodon.org/methods/accounts/#update_credentials + """ + files = {"avatar": avatar, "header": header} + files = {k: v for k, v in files.items() if v is not None} + + data = { + "bot": str_bool_nullable(bot), + "discoverable": str_bool_nullable(discoverable), + "display_name": display_name, + "locked": str_bool_nullable(locked), + "note": note, + "source[language]": language, + "source[privacy]": privacy, + "source[sensitive]": str_bool_nullable(sensitive), + } + data = {k: v for k, v in data.items() if v is not None} + + return http.patch(app, user, "/api/v1/accounts/update_credentials", files=files, data=data) + + def fetch_app_token(app): json = { "client_id": app.client_id, diff --git a/toot/commands.py b/toot/commands.py index 10d6ac2..9941e23 100644 --- a/toot/commands.py +++ b/toot/commands.py @@ -233,6 +233,41 @@ def env(app, user, args): print_out(platform.platform()) +def update_account(app, user, args): + options = [ + args.avatar, + args.bot, + args.discoverable, + args.display_name, + args.header, + args.language, + args.locked, + args.note, + args.privacy, + args.sensitive, + ] + + if all(option is None for option in options): + raise ConsoleError("Please specify at least one option to update the account") + + api.update_account( + app, + user, + avatar=args.avatar, + bot=args.bot, + discoverable=args.discoverable, + display_name=args.display_name, + header=args.header, + language=args.language, + locked=args.locked, + note=args.note, + privacy=args.privacy, + sensitive=args.sensitive, + ) + + print_out("✓ Account updated") + + def login_cli(app, user, args): app = create_app_interactive(instance=args.instance, scheme=args.scheme) login_interactive(app, args.email) diff --git a/toot/console.py b/toot/console.py index ee41a17..f1f6994 100644 --- a/toot/console.py +++ b/toot/console.py @@ -4,16 +4,61 @@ import re import shutil import sys -from argparse import ArgumentParser, FileType, ArgumentTypeError +from argparse import ArgumentParser, FileType, ArgumentTypeError, Action from collections import namedtuple from itertools import chain from toot import config, commands, CLIENT_NAME, CLIENT_WEBSITE, __version__ from toot.exceptions import ApiError, ConsoleError from toot.output import print_out, print_err -VISIBILITY_CHOICES = ['public', 'unlisted', 'private', 'direct'] +VISIBILITY_CHOICES = ["public", "unlisted", "private", "direct"] VISIBILITY_CHOICES_STR = ", ".join(f"'{v}'" for v in VISIBILITY_CHOICES) +PRIVACY_CHOICES = ["public", "unlisted", "private"] +PRIVACY_CHOICES_STR = ", ".join(f"'{v}'" for v in PRIVACY_CHOICES) + + +class BooleanOptionalAction(Action): + """ + Backported from argparse. This action is available since Python 3.9. + https://github.com/python/cpython/blob/3.11/Lib/argparse.py + """ + def __init__(self, + option_strings, + dest, + default=None, + type=None, + choices=None, + required=False, + help=None, + metavar=None): + + _option_strings = [] + for option_string in option_strings: + _option_strings.append(option_string) + + if option_string.startswith('--'): + option_string = '--no-' + option_string[2:] + _option_strings.append(option_string) + + super().__init__( + option_strings=_option_strings, + dest=dest, + nargs=0, + default=default, + type=type, + choices=choices, + required=required, + help=help, + metavar=metavar) + + def __call__(self, parser, namespace, values, option_string=None): + if option_string in self.option_strings: + setattr(namespace, self.dest, not option_string.startswith('--no-')) + + def format_usage(self): + return ' | '.join(self.option_strings) + def get_default_visibility(): return os.getenv("TOOT_POST_VISIBILITY", "public") @@ -38,6 +83,14 @@ def visibility(value): return value +def privacy(value): + """Validates the privacy parameter""" + if value not in PRIVACY_CHOICES: + raise ValueError(f"Invalid privacy value. Expected one of {PRIVACY_CHOICES_STR}.") + + return value + + def timeline_count(value): n = int(value) if not 0 < n <= 20: @@ -253,6 +306,53 @@ AUTH_COMMANDS = [ arguments=[], require_auth=False, ), + Command( + name="update_account", + description="Update your account details", + arguments=[ + (["--display-name"], { + "type": str, + "help": "The display name to use for the profile.", + }), + (["--note"], { + "type": str, + "help": "The account bio.", + }), + (["--avatar"], { + "type": FileType("rb"), + "help": "Path to the avatar image to set.", + }), + (["--header"], { + "type": FileType("rb"), + "help": "Path to the header image to set.", + }), + (["--bot"], { + "action": BooleanOptionalAction, + "help": "Whether the account has a bot flag.", + }), + (["--discoverable"], { + "action": BooleanOptionalAction, + "help": "Whether the account should be shown in the profile directory.", + }), + (["--locked"], { + "action": BooleanOptionalAction, + "help": "Whether manual approval of follow requests is required.", + }), + (["--privacy"], { + "type": privacy, + "help": f"Default post privacy for authored statuses. One of: {PRIVACY_CHOICES_STR}." + }), + (["--sensitive"], { + "action": BooleanOptionalAction, + "help": "Whether to mark authored statuses as sensitive by default." + }), + (["--language"], { + "type": language, + "help": "Default language to use for authored statuses (ISO 6391)." + }), + ], + require_auth=True, + ), ] TUI_COMMANDS = [ diff --git a/toot/http.py b/toot/http.py index b60a956..597edc9 100644 --- a/toot/http.py +++ b/toot/http.py @@ -80,6 +80,18 @@ def post(app, user, path, headers=None, files=None, data=None, json=None, allow_ return anon_post(url, headers=headers, files=files, data=data, json=json, allow_redirects=allow_redirects) +def patch(app, user, path, headers=None, files=None, data=None, json=None): + url = app.base_url + path + + headers = headers or {} + headers["Authorization"] = f"Bearer {user.access_token}" + + request = Request('PATCH', url, headers=headers, files=files, data=data, json=json) + response = send_request(request) + + return process_response(response) + + def delete(app, user, path, data=None, headers=None): url = app.base_url + path diff --git a/toot/utils/__init__.py b/toot/utils/__init__.py index d33d2d9..73ab6af 100644 --- a/toot/utils/__init__.py +++ b/toot/utils/__init__.py @@ -16,6 +16,11 @@ def str_bool(b): return "true" if b else "false" +def str_bool_nullable(b): + """Similar to str_bool, but leave None as None""" + return None if b is None else str_bool(b) + + def get_text(html): """Converts html to text, strips all tags."""