Added follow and unfollow commands - toot - Unnamed repository; edit this file 'description' to name the repository.
 (DIR) Log
 (DIR) Files
 (DIR) Refs
 (DIR) LICENSE
       ---
 (DIR) commit a493da5c84cafd717500f06708c5b0450501d932
 (DIR) parent 64d46955e2b827c406f503cabc1be8b6169e72dd
 (HTM) Author: Ivan Habunek <ivan@habunek.com>
       Date:   Sun, 16 Apr 2017 17:15:05 +0200
       
       Added follow and unfollow commands
       
       Diffstat:
         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(-)
       ---
 (DIR) diff --git a/README.rst b/README.rst
       @@ -39,6 +39,8 @@ Running ``toot <command> -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
 (DIR) diff --git 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
 (DIR) diff --git 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):
 (DIR) diff --git 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)
 (DIR) diff --git 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 <msg> - 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 <command> --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))