mirror of
https://github.com/ihabunek/toot.git
synced 2024-11-03 04:17:21 -05:00
commit
e370d76913
@ -35,6 +35,9 @@ HOSTNAME = os.getenv("TOOT_TEST_HOSTNAME")
|
|||||||
# Mastodon database name, used to confirm user registration without having to click the link
|
# Mastodon database name, used to confirm user registration without having to click the link
|
||||||
DATABASE_DSN = os.getenv("TOOT_TEST_DATABASE_DSN")
|
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:
|
if not HOSTNAME or not DATABASE_DSN:
|
||||||
pytest.skip("Skipping integration tests", allow_module_level=True)
|
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"
|
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
|
# Utils
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
|
40
toot/api.py
40
toot/api.py
@ -1,12 +1,12 @@
|
|||||||
import re
|
import re
|
||||||
from typing import List
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
from typing import List
|
||||||
from urllib.parse import urlparse, urlencode, quote
|
from urllib.parse import urlparse, urlencode, quote
|
||||||
|
|
||||||
from toot import http, CLIENT_NAME, CLIENT_WEBSITE
|
from toot import http, CLIENT_NAME, CLIENT_WEBSITE
|
||||||
from toot.exceptions import AuthenticationError
|
from toot.exceptions import AuthenticationError
|
||||||
from toot.utils import str_bool
|
from toot.utils import str_bool, str_bool_nullable
|
||||||
|
|
||||||
SCOPES = 'read write follow'
|
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()
|
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):
|
def fetch_app_token(app):
|
||||||
json = {
|
json = {
|
||||||
"client_id": app.client_id,
|
"client_id": app.client_id,
|
||||||
|
@ -233,6 +233,41 @@ def env(app, user, args):
|
|||||||
print_out(platform.platform())
|
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("<green>✓ Account updated</green>")
|
||||||
|
|
||||||
|
|
||||||
def login_cli(app, user, args):
|
def login_cli(app, user, args):
|
||||||
app = create_app_interactive(instance=args.instance, scheme=args.scheme)
|
app = create_app_interactive(instance=args.instance, scheme=args.scheme)
|
||||||
login_interactive(app, args.email)
|
login_interactive(app, args.email)
|
||||||
|
104
toot/console.py
104
toot/console.py
@ -4,16 +4,61 @@ import re
|
|||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from argparse import ArgumentParser, FileType, ArgumentTypeError
|
from argparse import ArgumentParser, FileType, ArgumentTypeError, Action
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
from toot import config, commands, CLIENT_NAME, CLIENT_WEBSITE, __version__
|
from toot import config, commands, CLIENT_NAME, CLIENT_WEBSITE, __version__
|
||||||
from toot.exceptions import ApiError, ConsoleError
|
from toot.exceptions import ApiError, ConsoleError
|
||||||
from toot.output import print_out, print_err
|
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)
|
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():
|
def get_default_visibility():
|
||||||
return os.getenv("TOOT_POST_VISIBILITY", "public")
|
return os.getenv("TOOT_POST_VISIBILITY", "public")
|
||||||
@ -38,6 +83,14 @@ def visibility(value):
|
|||||||
return 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):
|
def timeline_count(value):
|
||||||
n = int(value)
|
n = int(value)
|
||||||
if not 0 < n <= 20:
|
if not 0 < n <= 20:
|
||||||
@ -253,6 +306,53 @@ AUTH_COMMANDS = [
|
|||||||
arguments=[],
|
arguments=[],
|
||||||
require_auth=False,
|
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 = [
|
TUI_COMMANDS = [
|
||||||
|
12
toot/http.py
12
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)
|
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):
|
def delete(app, user, path, data=None, headers=None):
|
||||||
url = app.base_url + path
|
url = app.base_url + path
|
||||||
|
|
||||||
|
@ -16,6 +16,11 @@ def str_bool(b):
|
|||||||
return "true" if b else "false"
|
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):
|
def get_text(html):
|
||||||
"""Converts html to text, strips all tags."""
|
"""Converts html to text, strips all tags."""
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user