From a493da5c84cafd717500f06708c5b0450501d932 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Sun, 16 Apr 2017 17:15:05 +0200 Subject: [PATCH] Added follow and unfollow commands --- README.rst | 2 + tests/test_console.py | 113 ++++++++++++++++++++++++++++++++++++++---- tests/utils.py | 3 +- toot/api.py | 40 ++++++++++++++- toot/console.py | 77 ++++++++++++++++++++++++---- 5 files changed, 211 insertions(+), 24 deletions(-) diff --git a/README.rst b/README.rst index aa10874..44a8e65 100644 --- a/README.rst +++ b/README.rst @@ -39,6 +39,8 @@ Running ``toot -h`` shows the documentation for the given command. ``toot post`` Post a status to your timeline. ``toot search`` Search for accounts or hashtags. ``toot timeline`` Display recent items in your public timeline. + ``toot follow`` Follow an account. + ``toot unfollow`` Unfollow an account. =================== =============================================================== Authentication diff --git a/tests/test_console.py b/tests/test_console.py index 0b57baf..fd30418 100644 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -2,8 +2,7 @@ import pytest import requests -from toot import User, App -from toot.console import print_usage, cmd_post_status, cmd_timeline, cmd_upload, cmd_search +from toot import console, User, App from tests.utils import MockResponse @@ -12,7 +11,7 @@ user = User('ivan@habunek.com', 'xxx') def test_print_usagecap(capsys): - print_usage() + console.print_usage() out, err = capsys.readouterr() assert "toot - interact with Mastodon from the command line" in out @@ -36,7 +35,7 @@ def test_post_status_defaults(monkeypatch, capsys): monkeypatch.setattr(requests.Request, 'prepare', mock_prepare) monkeypatch.setattr(requests.Session, 'send', mock_send) - cmd_post_status(app, user, ['Hello world']) + console.cmd_post_status(app, user, ['Hello world']) out, err = capsys.readouterr() assert "Toot posted" in out @@ -63,7 +62,7 @@ def test_post_status_with_options(monkeypatch, capsys): args = ['"Hello world"', '--visibility', 'unlisted'] - cmd_post_status(app, user, args) + console.cmd_post_status(app, user, args) out, err = capsys.readouterr() assert "Toot posted" in out @@ -73,7 +72,7 @@ def test_post_status_invalid_visibility(monkeypatch, capsys): args = ['Hello world', '--visibility', 'foo'] with pytest.raises(SystemExit): - cmd_post_status(app, user, args) + console.cmd_post_status(app, user, args) out, err = capsys.readouterr() assert "invalid visibility value: 'foo'" in err @@ -83,7 +82,7 @@ def test_post_status_invalid_media(monkeypatch, capsys): args = ['Hello world', '--media', 'does_not_exist.jpg'] with pytest.raises(SystemExit): - cmd_post_status(app, user, args) + console.cmd_post_status(app, user, args) out, err = capsys.readouterr() assert "can't open 'does_not_exist.jpg'" in err @@ -107,7 +106,7 @@ def test_timeline(monkeypatch, capsys): monkeypatch.setattr(requests, 'get', mock_get) - cmd_timeline(app, user, []) + console.cmd_timeline(app, user, []) out, err = capsys.readouterr() assert "The computer can't tell you the emotional story." in out @@ -133,7 +132,7 @@ def test_upload(monkeypatch, capsys): monkeypatch.setattr(requests.Request, 'prepare', mock_prepare) monkeypatch.setattr(requests.Session, 'send', mock_send) - cmd_upload(app, user, [__file__]) + console.cmd_upload(app, user, [__file__]) out, err = capsys.readouterr() assert "Uploading media" in out @@ -163,10 +162,104 @@ def test_search(monkeypatch, capsys): monkeypatch.setattr(requests, 'get', mock_get) - cmd_search(app, user, ['freddy']) + console.cmd_search(app, user, ['freddy']) out, err = capsys.readouterr() assert "Hashtags:\n\033[32m#foo\033[0m, \033[32m#bar\033[0m, \033[32m#baz\033[0m" in out assert "Accounts:" in out assert "\033[32m@thequeen\033[0m Freddy Mercury" in out assert "\033[32m@thequeen@other.instance\033[0m Mercury Freddy" in out + + +def test_follow(monkeypatch, capsys): + def mock_get(url, params, headers): + assert url == 'https://habunek.com/api/v1/search' + assert params == {'q': 'blixa', 'resolve': False} + assert headers == {'Authorization': 'Bearer xxx'} + + return MockResponse({ + 'accounts': [ + {'id': 123, 'acct': 'blixa@other.acc'}, + {'id': 321, 'acct': 'blixa'}, + ] + }) + + def mock_prepare(request): + assert request.url == 'https://habunek.com/api/v1/accounts/321/follow' + + def mock_send(*args, **kwargs): + return MockResponse() + + monkeypatch.setattr(requests.Request, 'prepare', mock_prepare) + monkeypatch.setattr(requests.Session, 'send', mock_send) + monkeypatch.setattr(requests, 'get', mock_get) + + console.cmd_follow(app, user, ['blixa']) + + out, err = capsys.readouterr() + assert "You are now following blixa" in out + + +def test_follow_not_found(monkeypatch, capsys): + def mock_get(url, params, headers): + assert url == 'https://habunek.com/api/v1/search' + assert params == {'q': 'blixa', 'resolve': False} + assert headers == {'Authorization': 'Bearer xxx'} + + return MockResponse({ + 'accounts': [] + }) + + monkeypatch.setattr(requests, 'get', mock_get) + + console.cmd_follow(app, user, ['blixa']) + + out, err = capsys.readouterr() + assert "Account not found" in err + + +def test_unfollow(monkeypatch, capsys): + def mock_get(url, params, headers): + assert url == 'https://habunek.com/api/v1/search' + assert params == {'q': 'blixa', 'resolve': False} + assert headers == {'Authorization': 'Bearer xxx'} + + return MockResponse({ + 'accounts': [ + {'id': 123, 'acct': 'blixa@other.acc'}, + {'id': 321, 'acct': 'blixa'}, + ] + }) + + def mock_prepare(request): + assert request.url == 'https://habunek.com/api/v1/accounts/321/unfollow' + + def mock_send(*args, **kwargs): + return MockResponse() + + monkeypatch.setattr(requests.Request, 'prepare', mock_prepare) + monkeypatch.setattr(requests.Session, 'send', mock_send) + monkeypatch.setattr(requests, 'get', mock_get) + + console.cmd_unfollow(app, user, ['blixa']) + + out, err = capsys.readouterr() + assert "You are no longer following blixa" in out + + +def test_unfollow_not_found(monkeypatch, capsys): + def mock_get(url, params, headers): + assert url == 'https://habunek.com/api/v1/search' + assert params == {'q': 'blixa', 'resolve': False} + assert headers == {'Authorization': 'Bearer xxx'} + + return MockResponse({ + 'accounts': [] + }) + + monkeypatch.setattr(requests, 'get', mock_get) + + console.cmd_unfollow(app, user, ['blixa']) + + out, err = capsys.readouterr() + assert "Account not found" in err diff --git a/tests/utils.py b/tests/utils.py index 765dc7e..4cb1e0e 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,6 +1,7 @@ class MockResponse: - def __init__(self, response_data={}): + def __init__(self, response_data={}, ok=True): + self.ok = ok self.response_data = response_data def raise_for_status(self): diff --git a/toot/api.py b/toot/api.py index 92b7707..61dd9d6 100644 --- a/toot/api.py +++ b/toot/api.py @@ -4,6 +4,7 @@ import logging import requests from requests import Request, Session +from future.moves.urllib.parse import quote_plus from toot import App, User, CLIENT_NAME, CLIENT_WEBSITE @@ -12,6 +13,14 @@ SCOPES = 'read write follow' logger = logging.getLogger('toot') +class ApiError(Exception): + pass + + +class NotFoundError(ApiError): + pass + + def _log_request(request, prepared_request): logger.debug(">>> \033[32m{} {}\033[0m".format(request.method, request.url)) logger.debug(">>> DATA: \033[33m{}\033[0m".format(request.data)) @@ -20,8 +29,12 @@ def _log_request(request, prepared_request): def _log_response(response): - logger.debug("<<< \033[32m{}\033[0m".format(response)) - logger.debug("<<< \033[33m{}\033[0m".format(response.json())) + if response.ok: + logger.debug("<<< \033[32m{}\033[0m".format(response)) + logger.debug("<<< \033[33m{}\033[0m".format(response.json())) + else: + logger.debug("<<< \033[31m{}\033[0m".format(response)) + logger.debug("<<< \033[31m{}\033[0m".format(response.content)) def _get(app, user, url, params=None): @@ -48,6 +61,17 @@ def _post(app, user, url, data=None, files=None): _log_response(response) + if not response.ok: + try: + error = response.json()['error'] + except: + error = "Unknown error" + + if response.status_code == 404: + raise NotFoundError(error) + + raise ApiError(error) + response.raise_for_status() return response.json() @@ -115,3 +139,15 @@ def search(app, user, query, resolve): 'q': query, 'resolve': resolve, }) + + +def follow(app, user, account): + url = '/api/v1/accounts/%d/follow' % account + + return _post(app, user, url) + + +def unfollow(app, user, account): + url = '/api/v1/accounts/%d/unfollow' % account + + return _post(app, user, url) diff --git a/toot/console.py b/toot/console.py index c0c07c7..8ca84fe 100644 --- a/toot/console.py +++ b/toot/console.py @@ -15,8 +15,8 @@ from itertools import chain from argparse import ArgumentParser, FileType from textwrap import TextWrapper -from toot import DEFAULT_INSTANCE -from toot.api import create_app, login, post_status, timeline_home, upload_media, search +from toot import api, DEFAULT_INSTANCE +from toot.api import ApiError from toot.config import save_user, load_user, load_app, save_app, CONFIG_APP_FILE, CONFIG_USER_FILE @@ -49,7 +49,7 @@ def create_app_interactive(): print("Registering application with %s" % green(base_url)) try: - app = create_app(base_url) + app = api.create_app(base_url) except: raise ConsoleError("Failed authenticating application. Did you enter a valid instance?") @@ -66,7 +66,7 @@ def login_interactive(app): print("Authenticating...") try: - user = login(app, email, password) + user = api.login(app, email, password) except: raise ConsoleError("Login failed") @@ -86,6 +86,8 @@ def print_usage(): print(" toot post - toot a new post to your timeline") print(" toot search - search for accounts or hashtags") print(" toot timeline - shows your public timeline") + print(" toot follow - follow an account") + print(" toot unfollow - unfollow an account") print("") print("To get help for each command run:") print(" toot --help") @@ -140,7 +142,7 @@ def cmd_timeline(app, user, args): args = parser.parse_args(args) - items = timeline_home(app, user) + items = api.timeline_home(app, user) parsed_items = [parse_timeline(t) for t in items] print("─" * 31 + "┬" + "─" * 88) @@ -174,7 +176,7 @@ def cmd_post_status(app, user, args): else: media_ids = None - response = post_status(app, user, args.text, media_ids=media_ids, visibility=args.visibility) + response = api.post_status(app, user, args.text, media_ids=media_ids, visibility=args.visibility) print("Toot posted: " + green(response.get('url'))) @@ -194,11 +196,11 @@ def cmd_auth(app, user, args): print("You are not logged in") -def cmd_login(): +def cmd_login(args): parser = ArgumentParser(prog="toot login", description="Log into a Mastodon instance", epilog="https://github.com/ihabunek/toot") - parser.parse_args() + parser.parse_args(args) app = create_app_interactive() user = login_interactive(app) @@ -264,7 +266,7 @@ def cmd_search(app, user, args): args = parser.parse_args(args) - response = search(app, user, args.query, args.resolve) + response = api.search(app, user, args.query, args.resolve) _print_accounts(response['accounts']) _print_hashtags(response['hashtags']) @@ -272,7 +274,52 @@ def cmd_search(app, user, args): def do_upload(app, user, file): print("Uploading media: {}".format(green(file.name))) - return upload_media(app, user, file) + return api.upload_media(app, user, file) + + +def _find_account(app, user, account_name): + """For a given account name, returns the Account object or None if not found.""" + response = api.search(app, user, account_name, False) + + for account in response['accounts']: + if account['acct'] == account_name: + return account + + +def cmd_follow(app, user, args): + parser = ArgumentParser(prog="toot follow", + description="Follow an account", + epilog="https://github.com/ihabunek/toot") + parser.add_argument("account", help="Account name, e.g. 'Gargron' or 'polymerwitch@toot.cat'") + args = parser.parse_args(args) + + account = _find_account(app, user, args.account) + + if not account: + print_error("Account not found") + return + + api.follow(app, user, account['id']) + + print(green(u"✓ You are now following %s" % args.account)) + + +def cmd_unfollow(app, user, args): + parser = ArgumentParser(prog="toot unfollow", + description="Unfollow an account", + epilog="https://github.com/ihabunek/toot") + parser.add_argument("account", help="Account name, e.g. 'Gargron' or 'polymerwitch@toot.cat'") + args = parser.parse_args(args) + + account = _find_account(app, user, args.account) + + if not account: + print_error("Account not found") + return + + api.unfollow(app, user, account['id']) + + print(green(u"✓ You are no longer following %s" % args.account)) def run_command(command, args): @@ -281,7 +328,7 @@ def run_command(command, args): # Commands which can run when not logged in if command == 'login': - return cmd_login() + return cmd_login(args) if command == 'auth': return cmd_auth(app, user, args) @@ -307,6 +354,12 @@ def run_command(command, args): if command == 'search': return cmd_search(app, user, args) + if command == 'follow': + return cmd_follow(app, user, args) + + if command == 'unfollow': + return cmd_unfollow(app, user, args) + print_error("Unknown command '{}'\n".format(command)) print_usage() @@ -325,3 +378,5 @@ def main(): run_command(command, args) except ConsoleError as e: print_error(str(e)) + except ApiError as e: + print_error(str(e))