diff --git a/tests/test_api.py b/tests/test_api.py
index d30eb56..4aedd37 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -2,79 +2,76 @@
import pytest
import requests
+from requests import Request
+
from toot import App, CLIENT_NAME, CLIENT_WEBSITE
from toot.api import create_app, login, SCOPES, AuthenticationError
-from tests.utils import MockResponse
+from tests.utils import MockResponse, Expectations
def test_create_app(monkeypatch):
- response = {
- 'client_id': 'foo',
- 'client_secret': 'bar',
- }
+ request = Request('POST', 'http://bigfish.software/api/v1/apps',
+ data={'website': CLIENT_WEBSITE,
+ 'client_name': CLIENT_NAME,
+ 'scopes': SCOPES,
+ 'redirect_uris': 'urn:ietf:wg:oauth:2.0:oob'})
- def mock_post(url, data):
- assert url == 'https://bigfish.software/api/v1/apps'
- assert data == {
- 'website': CLIENT_WEBSITE,
- 'client_name': CLIENT_NAME,
- 'scopes': SCOPES,
- 'redirect_uris': 'urn:ietf:wg:oauth:2.0:oob'
- }
- return MockResponse(response)
+ response = MockResponse({'client_id': 'foo',
+ 'client_secret': 'bar'})
- monkeypatch.setattr(requests, 'post', mock_post)
+ e = Expectations()
+ e.add(request, response)
+ e.patch(monkeypatch)
- assert create_app('bigfish.software') == response
+ create_app('bigfish.software')
def test_login(monkeypatch):
app = App('bigfish.software', 'https://bigfish.software', 'foo', 'bar')
- response = {
+ data = {
+ 'grant_type': 'password',
+ 'client_id': app.client_id,
+ 'client_secret': app.client_secret,
+ 'username': 'user',
+ 'password': 'pass',
+ 'scope': SCOPES,
+ }
+
+ request = Request('POST', 'https://bigfish.software/oauth/token', data=data)
+
+ response = MockResponse({
'token_type': 'bearer',
'scope': 'read write follow',
'access_token': 'xxx',
'created_at': 1492523699
- }
+ })
- def mock_post(url, data, allow_redirects):
- assert not allow_redirects
- assert url == 'https://bigfish.software/oauth/token'
- assert data == {
- 'grant_type': 'password',
- 'client_id': app.client_id,
- 'client_secret': app.client_secret,
- 'username': 'user',
- 'password': 'pass',
- 'scope': SCOPES,
- }
+ e = Expectations()
+ e.add(request, response)
+ e.patch(monkeypatch)
- return MockResponse(response)
-
- monkeypatch.setattr(requests, 'post', mock_post)
-
- assert login(app, 'user', 'pass') == response
+ login(app, 'user', 'pass')
def test_login_failed(monkeypatch):
app = App('bigfish.software', 'https://bigfish.software', 'foo', 'bar')
- def mock_post(url, data, allow_redirects):
- assert not allow_redirects
- assert url == 'https://bigfish.software/oauth/token'
- assert data == {
- 'grant_type': 'password',
- 'client_id': app.client_id,
- 'client_secret': app.client_secret,
- 'username': 'user',
- 'password': 'pass',
- 'scope': SCOPES,
- }
+ data = {
+ 'grant_type': 'password',
+ 'client_id': app.client_id,
+ 'client_secret': app.client_secret,
+ 'username': 'user',
+ 'password': 'pass',
+ 'scope': SCOPES,
+ }
- return MockResponse(is_redirect=True)
+ request = Request('POST', 'https://bigfish.software/oauth/token', data=data)
+ response = MockResponse(is_redirect=True)
- monkeypatch.setattr(requests, 'post', mock_post)
+ e = Expectations()
+ e.add(request, response)
+ e.patch(monkeypatch)
with pytest.raises(AuthenticationError):
login(app, 'user', 'pass')
diff --git a/tests/test_auth.py b/tests/test_auth.py
index 851eada..2d1d501 100644
--- a/tests/test_auth.py
+++ b/tests/test_auth.py
@@ -15,6 +15,7 @@ def test_register_app(monkeypatch):
assert app.client_secret == "cs"
monkeypatch.setattr(api, 'create_app', retval(app_data))
+ monkeypatch.setattr(api, 'get_instance', retval({"title": "foo", "version": "1"}))
monkeypatch.setattr(config, 'save_app', assert_app)
app = auth.register_app("foo.bar")
diff --git a/tests/test_console.py b/tests/test_console.py
index 94dd547..0c6ff31 100644
--- a/tests/test_console.py
+++ b/tests/test_console.py
@@ -3,10 +3,12 @@ import pytest
import requests
import re
+from requests import Request
+
from toot import console, User, App
from toot.exceptions import ConsoleError
-from tests.utils import MockResponse
+from tests.utils import MockResponse, Expectations
app = App('habunek.com', 'https://habunek.com', 'foo', 'bar')
user = User('habunek.com', 'ivan@habunek.com', 'xxx')
@@ -34,7 +36,7 @@ def test_post_defaults(monkeypatch, capsys):
'media_ids[]': None,
}
- def mock_send(*args):
+ def mock_send(*args, **kwargs):
return MockResponse({
'url': 'http://ivan.habunek.com/'
})
@@ -59,7 +61,7 @@ def test_post_with_options(monkeypatch, capsys):
'media_ids[]': None,
}
- def mock_send(*args):
+ def mock_send(*args, **kwargs):
return MockResponse({
'url': 'http://ivan.habunek.com/'
})
@@ -96,11 +98,12 @@ def test_post_invalid_media(monkeypatch, capsys):
def test_timeline(monkeypatch, capsys):
- def mock_get(url, params, headers=None):
- assert url == 'https://habunek.com/api/v1/timelines/home'
- assert headers == {'Authorization': 'Bearer xxx'}
- assert params is None
+ def mock_prepare(request):
+ assert request.url == 'https://habunek.com/api/v1/timelines/home'
+ assert request.headers == {'Authorization': 'Bearer xxx'}
+ assert request.params == {}
+ def mock_send(*args, **kwargs):
return MockResponse([{
'account': {
'display_name': 'Frank Zappa',
@@ -111,7 +114,8 @@ def test_timeline(monkeypatch, capsys):
'reblog': None,
}])
- monkeypatch.setattr(requests, 'get', mock_get)
+ monkeypatch.setattr(requests.Request, 'prepare', mock_prepare)
+ monkeypatch.setattr(requests.Session, 'send', mock_send)
console.run_command(app, user, 'timeline', [])
@@ -127,7 +131,7 @@ def test_upload(monkeypatch, capsys):
assert request.headers == {'Authorization': 'Bearer xxx'}
assert request.files.get('file') is not None
- def mock_send(*args):
+ def mock_send(*args, **kwargs):
return MockResponse({
'id': 123,
'url': 'https://bigfish.software/123/456',
@@ -147,14 +151,15 @@ def test_upload(monkeypatch, capsys):
def test_search(monkeypatch, capsys):
- def mock_get(url, params, headers=None):
- assert url == 'https://habunek.com/api/v1/search'
- assert headers == {'Authorization': 'Bearer xxx'}
- assert params == {
+ def mock_prepare(request):
+ assert request.url == 'https://habunek.com/api/v1/search'
+ assert request.headers == {'Authorization': 'Bearer xxx'}
+ assert request.params == {
'q': 'freddy',
'resolve': False,
}
+ def mock_send(*args, **kwargs):
return MockResponse({
'hashtags': ['foo', 'bar', 'baz'],
'accounts': [{
@@ -167,7 +172,8 @@ def test_search(monkeypatch, capsys):
'statuses': [],
})
- monkeypatch.setattr(requests, 'get', mock_get)
+ monkeypatch.setattr(requests.Request, 'prepare', mock_prepare)
+ monkeypatch.setattr(requests.Session, 'send', mock_send)
console.run_command(app, user, 'search', ['freddy'])
@@ -179,25 +185,20 @@ def test_search(monkeypatch, capsys):
def test_follow(monkeypatch, capsys):
- def mock_get(url, params, headers):
- assert url == 'https://habunek.com/api/v1/accounts/search'
- assert params == {'q': 'blixa'}
- assert headers == {'Authorization': 'Bearer xxx'}
+ req1 = Request('GET', 'https://habunek.com/api/v1/accounts/search',
+ params={'q': 'blixa'},
+ headers={'Authorization': 'Bearer xxx'})
+ res1 = MockResponse([
+ {'id': 123, 'acct': 'blixa@other.acc'},
+ {'id': 321, 'acct': 'blixa'},
+ ])
- return MockResponse([
- {'id': 123, 'acct': 'blixa@other.acc'},
- {'id': 321, 'acct': 'blixa'},
- ])
+ req2 = Request('POST', 'https://habunek.com/api/v1/accounts/321/follow',
+ headers={'Authorization': 'Bearer xxx'})
+ res2 = MockResponse()
- 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)
+ expectations = Expectations([req1, req2], [res1, res2])
+ expectations.patch(monkeypatch)
console.run_command(app, user, 'follow', ['blixa'])
@@ -206,14 +207,12 @@ def test_follow(monkeypatch, capsys):
def test_follow_not_found(monkeypatch, capsys):
- def mock_get(url, params, headers):
- assert url == 'https://habunek.com/api/v1/accounts/search'
- assert params == {'q': 'blixa'}
- assert headers == {'Authorization': 'Bearer xxx'}
+ req = Request('GET', 'https://habunek.com/api/v1/accounts/search',
+ params={'q': 'blixa'}, headers={'Authorization': 'Bearer xxx'})
+ res = MockResponse()
- return MockResponse([])
-
- monkeypatch.setattr(requests, 'get', mock_get)
+ expectations = Expectations([req], [res])
+ expectations.patch(monkeypatch)
with pytest.raises(ConsoleError) as ex:
console.run_command(app, user, 'follow', ['blixa'])
@@ -221,25 +220,20 @@ def test_follow_not_found(monkeypatch, capsys):
def test_unfollow(monkeypatch, capsys):
- def mock_get(url, params, headers):
- assert url == 'https://habunek.com/api/v1/accounts/search'
- assert params == {'q': 'blixa'}
- assert headers == {'Authorization': 'Bearer xxx'}
+ req1 = Request('GET', 'https://habunek.com/api/v1/accounts/search',
+ params={'q': 'blixa'},
+ headers={'Authorization': 'Bearer xxx'})
+ res1 = MockResponse([
+ {'id': 123, 'acct': 'blixa@other.acc'},
+ {'id': 321, 'acct': 'blixa'},
+ ])
- return MockResponse([
- {'id': 123, 'acct': 'blixa@other.acc'},
- {'id': 321, 'acct': 'blixa'},
- ])
+ req2 = Request('POST', 'https://habunek.com/api/v1/accounts/321/unfollow',
+ headers={'Authorization': 'Bearer xxx'})
+ res2 = MockResponse()
- 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)
+ expectations = Expectations([req1, req2], [res1, res2])
+ expectations.patch(monkeypatch)
console.run_command(app, user, 'unfollow', ['blixa'])
@@ -248,14 +242,12 @@ def test_unfollow(monkeypatch, capsys):
def test_unfollow_not_found(monkeypatch, capsys):
- def mock_get(url, params, headers):
- assert url == 'https://habunek.com/api/v1/accounts/search'
- assert params == {'q': 'blixa'}
- assert headers == {'Authorization': 'Bearer xxx'}
+ req = Request('GET', 'https://habunek.com/api/v1/accounts/search',
+ params={'q': 'blixa'}, headers={'Authorization': 'Bearer xxx'})
+ res = MockResponse([])
- return MockResponse([])
-
- monkeypatch.setattr(requests, 'get', mock_get)
+ expectations = Expectations([req], [res])
+ expectations.patch(monkeypatch)
with pytest.raises(ConsoleError) as ex:
console.run_command(app, user, 'unfollow', ['blixa'])
@@ -263,30 +255,29 @@ def test_unfollow_not_found(monkeypatch, capsys):
def test_whoami(monkeypatch, capsys):
- def mock_get(url, params, headers=None):
- assert url == 'https://habunek.com/api/v1/accounts/verify_credentials'
- assert headers == {'Authorization': 'Bearer xxx'}
- assert params is None
+ req = Request('GET', 'https://habunek.com/api/v1/accounts/verify_credentials',
+ headers={'Authorization': 'Bearer xxx'})
- return MockResponse({
- 'acct': 'ihabunek',
- 'avatar': 'https://files.mastodon.social/accounts/avatars/000/046/103/original/6a1304e135cac514.jpg?1491312434',
- 'avatar_static': 'https://files.mastodon.social/accounts/avatars/000/046/103/original/6a1304e135cac514.jpg?1491312434',
- 'created_at': '2017-04-04T13:23:09.777Z',
- 'display_name': 'Ivan Habunek',
- 'followers_count': 5,
- 'following_count': 9,
- 'header': '/headers/original/missing.png',
- 'header_static': '/headers/original/missing.png',
- 'id': 46103,
- 'locked': False,
- 'note': 'A developer.',
- 'statuses_count': 19,
- 'url': 'https://mastodon.social/@ihabunek',
- 'username': 'ihabunek'
- })
+ res = MockResponse({
+ 'acct': 'ihabunek',
+ 'avatar': 'https://files.mastodon.social/accounts/avatars/000/046/103/original/6a1304e135cac514.jpg?1491312434',
+ 'avatar_static': 'https://files.mastodon.social/accounts/avatars/000/046/103/original/6a1304e135cac514.jpg?1491312434',
+ 'created_at': '2017-04-04T13:23:09.777Z',
+ 'display_name': 'Ivan Habunek',
+ 'followers_count': 5,
+ 'following_count': 9,
+ 'header': '/headers/original/missing.png',
+ 'header_static': '/headers/original/missing.png',
+ 'id': 46103,
+ 'locked': False,
+ 'note': 'A developer.',
+ 'statuses_count': 19,
+ 'url': 'https://mastodon.social/@ihabunek',
+ 'username': 'ihabunek'
+ })
- monkeypatch.setattr(requests, 'get', mock_get)
+ expectations = Expectations([req], [res])
+ expectations.patch(monkeypatch)
console.run_command(app, user, 'whoami', [])
diff --git a/tests/utils.py b/tests/utils.py
index 34e0958..16b9332 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -1,3 +1,31 @@
+import requests
+
+
+class Expectations():
+ """Helper for mocking http requests"""
+ def __init__(self, requests=[], responses=[]):
+ self.requests = requests
+ self.responses = responses
+
+ def mock_prepare(self, request):
+ expected = self.requests.pop(0)
+ assert request.method == expected.method
+ assert request.url == expected.url
+ assert request.data == expected.data
+ assert request.headers == expected.headers
+ assert request.params == expected.params
+
+ def mock_send(self, *args, **kwargs):
+ return self.responses.pop(0)
+
+ def add(self, req, res):
+ self.requests.append(req)
+ self.responses.append(res)
+
+ def patch(self, monkeypatch):
+ monkeypatch.setattr(requests.Session, 'prepare_request', self.mock_prepare)
+ monkeypatch.setattr(requests.Session, 'send', self.mock_send)
+
class MockResponse:
def __init__(self, response_data={}, ok=True, is_redirect=False):
diff --git a/toot/api.py b/toot/api.py
index 05a8b6c..499153a 100644
--- a/toot/api.py
+++ b/toot/api.py
@@ -1,13 +1,11 @@
# -*- coding: utf-8 -*-
import re
-import requests
from urllib.parse import urlparse, urlencode
from toot import http, CLIENT_NAME, CLIENT_WEBSITE
-from toot.exceptions import ApiError, AuthenticationError, NotFoundError
-from toot.utils import domain_exists
+from toot.exceptions import AuthenticationError
SCOPES = 'read write follow'
@@ -18,31 +16,32 @@ def _account_action(app, user, account, action):
return http.post(app, user, url).json()
-def create_app(instance):
- base_url = 'https://' + instance
- url = base_url + '/api/v1/apps'
+def create_app(domain):
+ url = 'http://{}/api/v1/apps'.format(domain)
- response = requests.post(url, {
+ data = {
'client_name': CLIENT_NAME,
'redirect_uris': 'urn:ietf:wg:oauth:2.0:oob',
'scopes': SCOPES,
'website': CLIENT_WEBSITE,
- })
+ }
- return http.process_response(response).json()
+ return http.anon_post(url, data).json()
def login(app, username, password):
url = app.base_url + '/oauth/token'
- response = requests.post(url, {
+ data = {
'grant_type': 'password',
'client_id': app.client_id,
'client_secret': app.client_secret,
'username': username,
'password': password,
'scope': SCOPES,
- }, allow_redirects=False)
+ }
+
+ response = http.anon_post(url, data, allow_redirects=False)
# If auth fails, it redirects to the login page
if response.is_redirect:
@@ -64,13 +63,15 @@ def get_browser_login_url(app):
def request_access_token(app, authorization_code):
url = app.base_url + '/oauth/token'
- response = requests.post(url, {
+ data = {
'grant_type': 'authorization_code',
'client_id': app.client_id,
'client_secret': app.client_secret,
'code': authorization_code,
'redirect_uri': 'urn:ietf:wg:oauth:2.0:oob',
- }, allow_redirects=False)
+ }
+
+ response = http.anon_post(url, data, allow_redirects=False)
return http.process_response(response).json()
@@ -155,16 +156,6 @@ def get_notifications(app, user):
return http.get(app, user, '/api/v1/notifications').json()
-def get_instance(app, user, domain):
- if not domain_exists(domain):
- raise ApiError("Domain {} not found".format(domain))
-
+def get_instance(domain):
url = "http://{}/api/v1/instance".format(domain)
-
- try:
- return http.unauthorized_get(url).json()
- except NotFoundError:
- raise ApiError(
- "Instance info not found at {}.\n"
- "The given domain probably does not host a Mastodon instance.".format(url)
- )
+ return http.anon_get(url).json()
diff --git a/toot/auth.py b/toot/auth.py
index 57405a1..bdf67cd 100644
--- a/toot/auth.py
+++ b/toot/auth.py
@@ -10,17 +10,22 @@ from toot.exceptions import ApiError, ConsoleError
from toot.output import print_out
-def register_app(instance):
- print_out("Registering application with {}".format(instance))
+def register_app(domain):
+ print_out("Looking up instance info...")
+ instance = api.get_instance(domain)
+
+ print_out("Found instance {} running Mastodon version {}".format(
+ instance['title'], instance['version']))
try:
- response = api.create_app(instance)
- except Exception:
- raise ConsoleError("Registration failed. Did you enter a valid instance?")
+ print_out("Registering application...")
+ response = api.create_app(domain)
+ except ApiError:
+ raise ConsoleError("Registration failed.")
- base_url = 'https://' + instance
+ base_url = 'https://' + domain
- app = App(instance, base_url, response['client_id'], response['client_secret'])
+ app = App(domain, base_url, response['client_id'], response['client_secret'])
path = config.save_app(app)
print_out("Application tokens saved to: {}\n".format(path))
diff --git a/toot/commands.py b/toot/commands.py
index 1716137..7cf4c86 100644
--- a/toot/commands.py
+++ b/toot/commands.py
@@ -8,8 +8,9 @@ from textwrap import TextWrapper
from toot import api, config
from toot.auth import login_interactive, login_browser_interactive, create_app_interactive
-from toot.exceptions import ConsoleError
+from toot.exceptions import ConsoleError, NotFoundError
from toot.output import print_out, print_instance, print_account, print_search_results
+from toot.utils import assert_domain_exists
def _print_timeline(item):
@@ -207,5 +208,13 @@ def instance(app, user, args):
if not name:
raise ConsoleError("Please specify instance name.")
- instance = api.get_instance(app, user, name)
- print_instance(instance)
+ assert_domain_exists(name)
+
+ try:
+ instance = api.get_instance(name)
+ print_instance(instance)
+ except NotFoundError:
+ raise ConsoleError(
+ "Instance not found at {}.\n"
+ "The given domain probably does not host a Mastodon instance.".format(name)
+ )
diff --git a/toot/http.py b/toot/http.py
index 5665bd7..87ad374 100644
--- a/toot/http.py
+++ b/toot/http.py
@@ -1,24 +1,37 @@
-import requests
-
-from toot.logging import log_request, log_response
from requests import Request, Session
from toot.exceptions import NotFoundError, ApiError
+from toot.logging import log_request, log_response
+
+
+def send_request(request, allow_redirects=True):
+ log_request(request)
+
+ with Session() as session:
+ prepared = session.prepare_request(request)
+ response = session.send(prepared, allow_redirects=allow_redirects)
+
+ log_response(response)
+
+ return response
+
+
+def _get_error_message(response):
+ """Attempt to extract an error message from response body"""
+ try:
+ data = response.json()
+ if "error_description" in data:
+ return data['error_description']
+ if "error" in data:
+ return data['error']
+ except Exception:
+ pass
+
+ return "Unknown error"
def process_response(response):
- log_response(response)
-
if not response.ok:
- error = "Unknown error"
-
- try:
- data = response.json()
- if "error_description" in data:
- error = data['error_description']
- elif "error" in data:
- error = data['error']
- except Exception:
- pass
+ error = _get_error_message(response)
if response.status_code == 404:
raise NotFoundError(error)
@@ -32,31 +45,31 @@ def get(app, user, url, params=None):
url = app.base_url + url
headers = {"Authorization": "Bearer " + user.access_token}
- log_request(Request('GET', url, headers, params=params))
-
- response = requests.get(url, params, headers=headers)
+ request = Request('GET', url, headers, params=params)
+ response = send_request(request)
return process_response(response)
-def unauthorized_get(url, params=None):
- log_request(Request('GET', url, None, params=params))
-
- response = requests.get(url, params)
+def anon_get(url, params=None):
+ request = Request('GET', url, None, params=params)
+ response = send_request(request)
return process_response(response)
-def post(app, user, url, data=None, files=None):
+def post(app, user, url, data=None, files=None, allow_redirects=True):
url = app.base_url + url
headers = {"Authorization": "Bearer " + user.access_token}
- session = Session()
request = Request('POST', url, headers, files, data)
- prepared_request = request.prepare()
-
- log_request(request)
-
- response = session.send(prepared_request)
+ response = send_request(request, allow_redirects)
+
+ return process_response(response)
+
+
+def anon_post(url, data=None, files=None, allow_redirects=True):
+ request = Request('POST', url, {}, files, data)
+ response = send_request(request, allow_redirects)
return process_response(response)
diff --git a/toot/utils.py b/toot/utils.py
index dcc7c29..55f9724 100644
--- a/toot/utils.py
+++ b/toot/utils.py
@@ -5,6 +5,8 @@ import socket
from bs4 import BeautifulSoup
+from toot.exceptions import ConsoleError
+
def get_text(html):
"""Converts html to text, strips all tags."""
@@ -50,3 +52,8 @@ def domain_exists(name):
return True
except OSError:
return False
+
+
+def assert_domain_exists(domain):
+ if not domain_exists(domain):
+ raise ConsoleError("Domain {} not found".format(domain))