Added follow and unfollow commands
This commit is contained in:
parent
64d46955e2
commit
a493da5c84
|
@ -39,6 +39,8 @@ Running ``toot <command> -h`` shows the documentation for the given command.
|
||||||
``toot post`` Post a status to your timeline.
|
``toot post`` Post a status to your timeline.
|
||||||
``toot search`` Search for accounts or hashtags.
|
``toot search`` Search for accounts or hashtags.
|
||||||
``toot timeline`` Display recent items in your public timeline.
|
``toot timeline`` Display recent items in your public timeline.
|
||||||
|
``toot follow`` Follow an account.
|
||||||
|
``toot unfollow`` Unfollow an account.
|
||||||
=================== ===============================================================
|
=================== ===============================================================
|
||||||
|
|
||||||
Authentication
|
Authentication
|
||||||
|
|
|
@ -2,8 +2,7 @@
|
||||||
import pytest
|
import pytest
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from toot import User, App
|
from toot import console, User, App
|
||||||
from toot.console import print_usage, cmd_post_status, cmd_timeline, cmd_upload, cmd_search
|
|
||||||
|
|
||||||
from tests.utils import MockResponse
|
from tests.utils import MockResponse
|
||||||
|
|
||||||
|
@ -12,7 +11,7 @@ user = User('ivan@habunek.com', 'xxx')
|
||||||
|
|
||||||
|
|
||||||
def test_print_usagecap(capsys):
|
def test_print_usagecap(capsys):
|
||||||
print_usage()
|
console.print_usage()
|
||||||
out, err = capsys.readouterr()
|
out, err = capsys.readouterr()
|
||||||
assert "toot - interact with Mastodon from the command line" in out
|
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.Request, 'prepare', mock_prepare)
|
||||||
monkeypatch.setattr(requests.Session, 'send', mock_send)
|
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()
|
out, err = capsys.readouterr()
|
||||||
assert "Toot posted" in out
|
assert "Toot posted" in out
|
||||||
|
@ -63,7 +62,7 @@ def test_post_status_with_options(monkeypatch, capsys):
|
||||||
|
|
||||||
args = ['"Hello world"', '--visibility', 'unlisted']
|
args = ['"Hello world"', '--visibility', 'unlisted']
|
||||||
|
|
||||||
cmd_post_status(app, user, args)
|
console.cmd_post_status(app, user, args)
|
||||||
|
|
||||||
out, err = capsys.readouterr()
|
out, err = capsys.readouterr()
|
||||||
assert "Toot posted" in out
|
assert "Toot posted" in out
|
||||||
|
@ -73,7 +72,7 @@ def test_post_status_invalid_visibility(monkeypatch, capsys):
|
||||||
args = ['Hello world', '--visibility', 'foo']
|
args = ['Hello world', '--visibility', 'foo']
|
||||||
|
|
||||||
with pytest.raises(SystemExit):
|
with pytest.raises(SystemExit):
|
||||||
cmd_post_status(app, user, args)
|
console.cmd_post_status(app, user, args)
|
||||||
|
|
||||||
out, err = capsys.readouterr()
|
out, err = capsys.readouterr()
|
||||||
assert "invalid visibility value: 'foo'" in err
|
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']
|
args = ['Hello world', '--media', 'does_not_exist.jpg']
|
||||||
|
|
||||||
with pytest.raises(SystemExit):
|
with pytest.raises(SystemExit):
|
||||||
cmd_post_status(app, user, args)
|
console.cmd_post_status(app, user, args)
|
||||||
|
|
||||||
out, err = capsys.readouterr()
|
out, err = capsys.readouterr()
|
||||||
assert "can't open 'does_not_exist.jpg'" in err
|
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)
|
monkeypatch.setattr(requests, 'get', mock_get)
|
||||||
|
|
||||||
cmd_timeline(app, user, [])
|
console.cmd_timeline(app, user, [])
|
||||||
|
|
||||||
out, err = capsys.readouterr()
|
out, err = capsys.readouterr()
|
||||||
assert "The computer can't tell you the emotional story." in out
|
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.Request, 'prepare', mock_prepare)
|
||||||
monkeypatch.setattr(requests.Session, 'send', mock_send)
|
monkeypatch.setattr(requests.Session, 'send', mock_send)
|
||||||
|
|
||||||
cmd_upload(app, user, [__file__])
|
console.cmd_upload(app, user, [__file__])
|
||||||
|
|
||||||
out, err = capsys.readouterr()
|
out, err = capsys.readouterr()
|
||||||
assert "Uploading media" in out
|
assert "Uploading media" in out
|
||||||
|
@ -163,10 +162,104 @@ def test_search(monkeypatch, capsys):
|
||||||
|
|
||||||
monkeypatch.setattr(requests, 'get', mock_get)
|
monkeypatch.setattr(requests, 'get', mock_get)
|
||||||
|
|
||||||
cmd_search(app, user, ['freddy'])
|
console.cmd_search(app, user, ['freddy'])
|
||||||
|
|
||||||
out, err = capsys.readouterr()
|
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 "Hashtags:\n\033[32m#foo\033[0m, \033[32m#bar\033[0m, \033[32m#baz\033[0m" in out
|
||||||
assert "Accounts:" in out
|
assert "Accounts:" in out
|
||||||
assert "\033[32m@thequeen\033[0m Freddy Mercury" in out
|
assert "\033[32m@thequeen\033[0m Freddy Mercury" in out
|
||||||
assert "\033[32m@thequeen@other.instance\033[0m Mercury Freddy" 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
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
|
|
||||||
class MockResponse:
|
class MockResponse:
|
||||||
def __init__(self, response_data={}):
|
def __init__(self, response_data={}, ok=True):
|
||||||
|
self.ok = ok
|
||||||
self.response_data = response_data
|
self.response_data = response_data
|
||||||
|
|
||||||
def raise_for_status(self):
|
def raise_for_status(self):
|
||||||
|
|
40
toot/api.py
40
toot/api.py
|
@ -4,6 +4,7 @@ import logging
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from requests import Request, Session
|
from requests import Request, Session
|
||||||
|
from future.moves.urllib.parse import quote_plus
|
||||||
|
|
||||||
from toot import App, User, CLIENT_NAME, CLIENT_WEBSITE
|
from toot import App, User, CLIENT_NAME, CLIENT_WEBSITE
|
||||||
|
|
||||||
|
@ -12,6 +13,14 @@ SCOPES = 'read write follow'
|
||||||
logger = logging.getLogger('toot')
|
logger = logging.getLogger('toot')
|
||||||
|
|
||||||
|
|
||||||
|
class ApiError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class NotFoundError(ApiError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _log_request(request, prepared_request):
|
def _log_request(request, prepared_request):
|
||||||
logger.debug(">>> \033[32m{} {}\033[0m".format(request.method, request.url))
|
logger.debug(">>> \033[32m{} {}\033[0m".format(request.method, request.url))
|
||||||
logger.debug(">>> DATA: \033[33m{}\033[0m".format(request.data))
|
logger.debug(">>> DATA: \033[33m{}\033[0m".format(request.data))
|
||||||
|
@ -20,8 +29,12 @@ def _log_request(request, prepared_request):
|
||||||
|
|
||||||
|
|
||||||
def _log_response(response):
|
def _log_response(response):
|
||||||
logger.debug("<<< \033[32m{}\033[0m".format(response))
|
if response.ok:
|
||||||
logger.debug("<<< \033[33m{}\033[0m".format(response.json()))
|
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):
|
def _get(app, user, url, params=None):
|
||||||
|
@ -48,6 +61,17 @@ def _post(app, user, url, data=None, files=None):
|
||||||
|
|
||||||
_log_response(response)
|
_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()
|
response.raise_for_status()
|
||||||
|
|
||||||
return response.json()
|
return response.json()
|
||||||
|
@ -115,3 +139,15 @@ def search(app, user, query, resolve):
|
||||||
'q': query,
|
'q': query,
|
||||||
'resolve': resolve,
|
'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)
|
||||||
|
|
|
@ -15,8 +15,8 @@ from itertools import chain
|
||||||
from argparse import ArgumentParser, FileType
|
from argparse import ArgumentParser, FileType
|
||||||
from textwrap import TextWrapper
|
from textwrap import TextWrapper
|
||||||
|
|
||||||
from toot import DEFAULT_INSTANCE
|
from toot import api, DEFAULT_INSTANCE
|
||||||
from toot.api import create_app, login, post_status, timeline_home, upload_media, search
|
from toot.api import ApiError
|
||||||
from toot.config import save_user, load_user, load_app, save_app, CONFIG_APP_FILE, CONFIG_USER_FILE
|
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))
|
print("Registering application with %s" % green(base_url))
|
||||||
try:
|
try:
|
||||||
app = create_app(base_url)
|
app = api.create_app(base_url)
|
||||||
except:
|
except:
|
||||||
raise ConsoleError("Failed authenticating application. Did you enter a valid instance?")
|
raise ConsoleError("Failed authenticating application. Did you enter a valid instance?")
|
||||||
|
|
||||||
|
@ -66,7 +66,7 @@ def login_interactive(app):
|
||||||
|
|
||||||
print("Authenticating...")
|
print("Authenticating...")
|
||||||
try:
|
try:
|
||||||
user = login(app, email, password)
|
user = api.login(app, email, password)
|
||||||
except:
|
except:
|
||||||
raise ConsoleError("Login failed")
|
raise ConsoleError("Login failed")
|
||||||
|
|
||||||
|
@ -86,6 +86,8 @@ def print_usage():
|
||||||
print(" toot post <msg> - toot a new post to your timeline")
|
print(" toot post <msg> - toot a new post to your timeline")
|
||||||
print(" toot search - search for accounts or hashtags")
|
print(" toot search - search for accounts or hashtags")
|
||||||
print(" toot timeline - shows your public timeline")
|
print(" toot timeline - shows your public timeline")
|
||||||
|
print(" toot follow - follow an account")
|
||||||
|
print(" toot unfollow - unfollow an account")
|
||||||
print("")
|
print("")
|
||||||
print("To get help for each command run:")
|
print("To get help for each command run:")
|
||||||
print(" toot <command> --help")
|
print(" toot <command> --help")
|
||||||
|
@ -140,7 +142,7 @@ def cmd_timeline(app, user, args):
|
||||||
|
|
||||||
args = parser.parse_args(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]
|
parsed_items = [parse_timeline(t) for t in items]
|
||||||
|
|
||||||
print("─" * 31 + "┬" + "─" * 88)
|
print("─" * 31 + "┬" + "─" * 88)
|
||||||
|
@ -174,7 +176,7 @@ def cmd_post_status(app, user, args):
|
||||||
else:
|
else:
|
||||||
media_ids = None
|
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')))
|
print("Toot posted: " + green(response.get('url')))
|
||||||
|
|
||||||
|
@ -194,11 +196,11 @@ def cmd_auth(app, user, args):
|
||||||
print("You are not logged in")
|
print("You are not logged in")
|
||||||
|
|
||||||
|
|
||||||
def cmd_login():
|
def cmd_login(args):
|
||||||
parser = ArgumentParser(prog="toot login",
|
parser = ArgumentParser(prog="toot login",
|
||||||
description="Log into a Mastodon instance",
|
description="Log into a Mastodon instance",
|
||||||
epilog="https://github.com/ihabunek/toot")
|
epilog="https://github.com/ihabunek/toot")
|
||||||
parser.parse_args()
|
parser.parse_args(args)
|
||||||
|
|
||||||
app = create_app_interactive()
|
app = create_app_interactive()
|
||||||
user = login_interactive(app)
|
user = login_interactive(app)
|
||||||
|
@ -264,7 +266,7 @@ def cmd_search(app, user, args):
|
||||||
|
|
||||||
args = parser.parse_args(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_accounts(response['accounts'])
|
||||||
_print_hashtags(response['hashtags'])
|
_print_hashtags(response['hashtags'])
|
||||||
|
@ -272,7 +274,52 @@ def cmd_search(app, user, args):
|
||||||
|
|
||||||
def do_upload(app, user, file):
|
def do_upload(app, user, file):
|
||||||
print("Uploading media: {}".format(green(file.name)))
|
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):
|
def run_command(command, args):
|
||||||
|
@ -281,7 +328,7 @@ def run_command(command, args):
|
||||||
|
|
||||||
# Commands which can run when not logged in
|
# Commands which can run when not logged in
|
||||||
if command == 'login':
|
if command == 'login':
|
||||||
return cmd_login()
|
return cmd_login(args)
|
||||||
|
|
||||||
if command == 'auth':
|
if command == 'auth':
|
||||||
return cmd_auth(app, user, args)
|
return cmd_auth(app, user, args)
|
||||||
|
@ -307,6 +354,12 @@ def run_command(command, args):
|
||||||
if command == 'search':
|
if command == 'search':
|
||||||
return cmd_search(app, user, args)
|
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_error("Unknown command '{}'\n".format(command))
|
||||||
print_usage()
|
print_usage()
|
||||||
|
|
||||||
|
@ -325,3 +378,5 @@ def main():
|
||||||
run_command(command, args)
|
run_command(command, args)
|
||||||
except ConsoleError as e:
|
except ConsoleError as e:
|
||||||
print_error(str(e))
|
print_error(str(e))
|
||||||
|
except ApiError as e:
|
||||||
|
print_error(str(e))
|
||||||
|
|
Loading…
Reference in New Issue