Rework how commands are defined - toot - Unnamed repository; edit this file 'description' to name the repository.
 (DIR) Log
 (DIR) Files
 (DIR) Refs
 (DIR) LICENSE
       ---
 (DIR) commit 373f26424d002b22c8fa7fe7eabc38e13275d1fc
 (DIR) parent 2a3d66bae50a7fff4d50319c192dacd5db12365e
 (HTM) Author: Ivan Habunek <ivan@habunek.com>
       Date:   Wed, 19 Apr 2017 14:47:30 +0200
       
       Rework how commands are defined
       
       Diffstat:
         README.rst                          |      38 +++++++++++++++++++------------
         tests/test_console.py               |      34 ++++++++++++++++----------------
         toot/__init__.py                    |       8 +++++++-
         toot/api.py                         |       2 +-
         toot/commands.py                    |     307 +++++++++++++++++++++++++++++++
         toot/console.py                     |     593 ++++++++-----------------------
         toot/output.py                      |      37 +++++++++++++++++++++++++++++++
       
       7 files changed, 543 insertions(+), 476 deletions(-)
       ---
 (DIR) diff --git a/README.rst b/README.rst
       @@ -34,20 +34,30 @@ Running ``toot`` displays a list of available commands.
        
        Running ``toot <command> -h`` shows the documentation for the given command.
        
       -===================  ===============================================================
       - Command              Description
       -===================  ===============================================================
       - ``toot login``       Log into a Mastodon instance.
       - ``toot 2fa``         Log into a Mastodon instance using two factor authentication.
       - ``toot logout``      Log out, deletes stored access keys.
       - ``toot auth``        Display stored authenitication tokens.
       - ``toot whoami``      Display logged in user details.
       - ``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.
       -===================  ===============================================================
       +.. code-block::
       +
       +    $ toot
       +
       +    toot - a Mastodon CLI client
       +
       +    Usage:
       +      toot login       Log into a Mastodon instance
       +      toot login_2fa   Log in using two factor authentication (experimental)
       +      toot logout      Log out, delete stored access keys
       +      toot auth        Show stored credentials
       +      toot whoami      Display logged in user details
       +      toot post        Post a status text to your timeline
       +      toot upload      Upload an image or video file
       +      toot search      Search for users or hashtags
       +      toot follow      Follow an account
       +      toot unfollow    Unfollow an account
       +      toot timeline    Show recent items in your public timeline
       +
       +    To get help for each command run:
       +      toot <command> --help
       +
       +    https://github.com/ihabunek/toot
       +
        
        Authentication
        --------------
 (DIR) diff --git a/tests/test_console.py b/tests/test_console.py
       @@ -19,10 +19,10 @@ def uncolorize(text):
        def test_print_usage(capsys):
            console.print_usage()
            out, err = capsys.readouterr()
       -    assert "toot - interact with Mastodon from the command line" in out
       +    assert "toot - a Mastodon CLI client" in out
        
        
       -def test_post_status_defaults(monkeypatch, capsys):
       +def test_post_defaults(monkeypatch, capsys):
            def mock_prepare(request):
                assert request.method == 'POST'
                assert request.url == 'https://habunek.com/api/v1/statuses'
       @@ -41,13 +41,13 @@ def test_post_status_defaults(monkeypatch, capsys):
            monkeypatch.setattr(requests.Request, 'prepare', mock_prepare)
            monkeypatch.setattr(requests.Session, 'send', mock_send)
        
       -    console.cmd_post_status(app, user, ['Hello world'])
       +    console.run_command(app, user, 'post', ['Hello world'])
        
            out, err = capsys.readouterr()
            assert "Toot posted" in out
        
        
       -def test_post_status_with_options(monkeypatch, capsys):
       +def test_post_with_options(monkeypatch, capsys):
            def mock_prepare(request):
                assert request.method == 'POST'
                assert request.url == 'https://habunek.com/api/v1/statuses'
       @@ -68,27 +68,27 @@ def test_post_status_with_options(monkeypatch, capsys):
        
            args = ['"Hello world"', '--visibility', 'unlisted']
        
       -    console.cmd_post_status(app, user, args)
       +    console.run_command(app, user, 'post', args)
        
            out, err = capsys.readouterr()
            assert "Toot posted" in out
        
        
       -def test_post_status_invalid_visibility(monkeypatch, capsys):
       +def test_post_invalid_visibility(monkeypatch, capsys):
            args = ['Hello world', '--visibility', 'foo']
        
            with pytest.raises(SystemExit):
       -        console.cmd_post_status(app, user, args)
       +        console.run_command(app, user, 'post', args)
        
            out, err = capsys.readouterr()
            assert "invalid visibility value: 'foo'" in err
        
        
       -def test_post_status_invalid_media(monkeypatch, capsys):
       +def test_post_invalid_media(monkeypatch, capsys):
            args = ['Hello world', '--media', 'does_not_exist.jpg']
        
            with pytest.raises(SystemExit):
       -        console.cmd_post_status(app, user, args)
       +        console.run_command(app, user, 'post', args)
        
            out, err = capsys.readouterr()
            assert "can't open 'does_not_exist.jpg'" in err
       @@ -112,7 +112,7 @@ def test_timeline(monkeypatch, capsys):
        
            monkeypatch.setattr(requests, 'get', mock_get)
        
       -    console.cmd_timeline(app, user, [])
       +    console.run_command(app, user, 'timeline', [])
        
            out, err = capsys.readouterr()
            assert "The computer can't tell you the emotional story." in out
       @@ -138,7 +138,7 @@ def test_upload(monkeypatch, capsys):
            monkeypatch.setattr(requests.Request, 'prepare', mock_prepare)
            monkeypatch.setattr(requests.Session, 'send', mock_send)
        
       -    console.cmd_upload(app, user, [__file__])
       +    console.run_command(app, user, 'upload', [__file__])
        
            out, err = capsys.readouterr()
            assert "Uploading media" in out
       @@ -168,7 +168,7 @@ def test_search(monkeypatch, capsys):
        
            monkeypatch.setattr(requests, 'get', mock_get)
        
       -    console.cmd_search(app, user, ['freddy'])
       +    console.run_command(app, user, 'search', ['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
       @@ -200,7 +200,7 @@ def test_follow(monkeypatch, capsys):
            monkeypatch.setattr(requests.Session, 'send', mock_send)
            monkeypatch.setattr(requests, 'get', mock_get)
        
       -    console.cmd_follow(app, user, ['blixa'])
       +    console.run_command(app, user, 'follow', ['blixa'])
        
            out, err = capsys.readouterr()
            assert "You are now following blixa" in out
       @@ -218,7 +218,7 @@ def test_follow_not_found(monkeypatch, capsys):
        
            monkeypatch.setattr(requests, 'get', mock_get)
        
       -    console.cmd_follow(app, user, ['blixa'])
       +    console.run_command(app, user, 'follow', ['blixa'])
        
            out, err = capsys.readouterr()
            assert "Account not found" in err
       @@ -247,7 +247,7 @@ def test_unfollow(monkeypatch, capsys):
            monkeypatch.setattr(requests.Session, 'send', mock_send)
            monkeypatch.setattr(requests, 'get', mock_get)
        
       -    console.cmd_unfollow(app, user, ['blixa'])
       +    console.run_command(app, user, 'unfollow', ['blixa'])
        
            out, err = capsys.readouterr()
            assert "You are no longer following blixa" in out
       @@ -265,7 +265,7 @@ def test_unfollow_not_found(monkeypatch, capsys):
        
            monkeypatch.setattr(requests, 'get', mock_get)
        
       -    console.cmd_unfollow(app, user, ['blixa'])
       +    console.run_command(app, user, 'unfollow', ['blixa'])
        
            out, err = capsys.readouterr()
            assert "Account not found" in err
       @@ -297,7 +297,7 @@ def test_whoami(monkeypatch, capsys):
        
            monkeypatch.setattr(requests, 'get', mock_get)
        
       -    console.cmd_whoami(app, user, [])
       +    console.run_command(app, user, 'whoami', [])
        
            out, err = capsys.readouterr()
            out = uncolorize(out)
 (DIR) diff --git a/toot/__init__.py b/toot/__init__.py
       @@ -1,4 +1,6 @@
        # -*- coding: utf-8 -*-
       +from __future__ import unicode_literals
       +from __future__ import print_function
        
        from collections import namedtuple
        
       @@ -7,5 +9,9 @@ User = namedtuple('User', ['instance', 'username', 'access_token'])
        
        DEFAULT_INSTANCE = 'mastodon.social'
        
       -CLIENT_NAME = 'toot - Mastodon CLI Interface'
       +CLIENT_NAME = 'toot - a Mastodon CLI client'
        CLIENT_WEBSITE = 'https://github.com/ihabunek/toot'
       +
       +
       +class ConsoleError(Exception):
       +    pass
 (DIR) diff --git a/toot/api.py b/toot/api.py
       @@ -5,7 +5,7 @@ import requests
        
        from requests import Request, Session
        
       -from toot import App, User, CLIENT_NAME, CLIENT_WEBSITE
       +from toot import CLIENT_NAME, CLIENT_WEBSITE
        
        SCOPES = 'read write follow'
        
 (DIR) diff --git a/toot/commands.py b/toot/commands.py
       @@ -0,0 +1,307 @@
       +# -*- coding: utf-8 -*-
       +from __future__ import unicode_literals
       +from __future__ import print_function
       +
       +import json
       +import requests
       +
       +from bs4 import BeautifulSoup
       +from builtins import input
       +from datetime import datetime
       +from future.moves.itertools import zip_longest
       +from getpass import getpass
       +from itertools import chain
       +from textwrap import TextWrapper
       +
       +from toot import api, config, DEFAULT_INSTANCE, User, App, ConsoleError
       +from toot.output import green, yellow, print_error
       +
       +
       +def register_app(instance):
       +    print("Registering application with %s" % green(instance))
       +
       +    try:
       +        response = api.create_app(instance)
       +    except:
       +        raise ConsoleError("Registration failed. Did you enter a valid instance?")
       +
       +    base_url = 'https://' + instance
       +
       +    app = App(instance, base_url, response['client_id'], response['client_secret'])
       +    path = config.save_app(app)
       +    print("Application tokens saved to: {}\n".format(green(path)))
       +
       +    return app
       +
       +
       +def create_app_interactive():
       +    instance = input("Choose an instance [%s]: " % green(DEFAULT_INSTANCE))
       +    if not instance:
       +        instance = DEFAULT_INSTANCE
       +
       +    return config.load_app(instance) or register_app(instance)
       +
       +
       +def login_interactive(app):
       +    print("\nLog in to " + green(app.instance))
       +    email = input('Email: ')
       +    password = getpass('Password: ')
       +
       +    if not email or not password:
       +        raise ConsoleError("Email and password cannot be empty.")
       +
       +    try:
       +        print("Authenticating...")
       +        response = api.login(app, email, password)
       +    except api.ApiError:
       +        raise ConsoleError("Login failed")
       +
       +    user = User(app.instance, email, response['access_token'])
       +    path = config.save_user(user)
       +    print("Access token saved to: " + green(path))
       +
       +    return user
       +
       +
       +def two_factor_login_interactive(app):
       +    """Hacky implementation of two factor authentication"""
       +
       +    print("Log in to " + green(app.instance))
       +    email = input('Email: ')
       +    password = getpass('Password: ')
       +
       +    sign_in_url = app.base_url + '/auth/sign_in'
       +
       +    session = requests.Session()
       +
       +    # Fetch sign in form
       +    response = session.get(sign_in_url)
       +    response.raise_for_status()
       +
       +    soup = BeautifulSoup(response.content, "html.parser")
       +    form = soup.find('form')
       +    inputs = form.find_all('input')
       +
       +    data = {i.attrs.get('name'): i.attrs.get('value') for i in inputs}
       +    data['user[email]'] = email
       +    data['user[password]'] = password
       +
       +    # Submit form, get 2FA entry form
       +    response = session.post(sign_in_url, data)
       +    response.raise_for_status()
       +
       +    soup = BeautifulSoup(response.content, "html.parser")
       +    form = soup.find('form')
       +    inputs = form.find_all('input')
       +
       +    data = {i.attrs.get('name'): i.attrs.get('value') for i in inputs}
       +    data['user[otp_attempt]'] = input("2FA Token: ")
       +
       +    # Submit token
       +    response = session.post(sign_in_url, data)
       +    response.raise_for_status()
       +
       +    # Extract access token from response
       +    soup = BeautifulSoup(response.content, "html.parser")
       +    initial_state = soup.find('script', id='initial-state')
       +
       +    if not initial_state:
       +        raise ConsoleError("Login failed: Invalid 2FA token?")
       +
       +    data = json.loads(initial_state.get_text())
       +    access_token = data['meta']['access_token']
       +
       +    user = User(app.instance, email, access_token)
       +    path = config.save_user(user)
       +    print("Access token saved to: " + green(path))
       +
       +
       +def _print_timeline(item):
       +    def wrap_text(text, width):
       +        wrapper = TextWrapper(width=width, break_long_words=False, break_on_hyphens=False)
       +        return chain(*[wrapper.wrap(l) for l in text.split("\n")])
       +
       +    def timeline_rows(item):
       +        name = item['name']
       +        time = item['time'].strftime('%Y-%m-%d %H:%M%Z')
       +
       +        left_column = [name, time]
       +        if 'reblogged' in item:
       +            left_column.append(item['reblogged'])
       +
       +        text = item['text']
       +
       +        right_column = wrap_text(text, 80)
       +
       +        return zip_longest(left_column, right_column, fillvalue="")
       +
       +    for left, right in timeline_rows(item):
       +        print("{:30} │ {}".format(left, right))
       +
       +
       +def _parse_timeline(item):
       +    content = item['reblog']['content'] if item['reblog'] else item['content']
       +    reblogged = item['reblog']['account']['username'] if item['reblog'] else ""
       +
       +    name = item['account']['display_name'] + " @" + item['account']['username']
       +    soup = BeautifulSoup(content, "html.parser")
       +    text = soup.get_text().replace('&apos;', "'")
       +    time = datetime.strptime(item['created_at'], "%Y-%m-%dT%H:%M:%S.%fZ")
       +
       +    return {
       +        "name": name,
       +        "text": text,
       +        "time": time,
       +        "reblogged": reblogged,
       +    }
       +
       +
       +def timeline(app, user, args):
       +    items = api.timeline_home(app, user)
       +    parsed_items = [_parse_timeline(t) for t in items]
       +
       +    print("─" * 31 + "┬" + "─" * 88)
       +    for item in parsed_items:
       +        _print_timeline(item)
       +        print("─" * 31 + "┼" + "─" * 88)
       +
       +
       +def post(app, user, args):
       +    if args.media:
       +        media = _do_upload(app, user, args.media)
       +        media_ids = [media['id']]
       +    else:
       +        media_ids = None
       +
       +    response = api.post_status(app, user, args.text, media_ids=media_ids, visibility=args.visibility)
       +
       +    print("Toot posted: " + green(response.get('url')))
       +
       +
       +def auth(app, user, args):
       +    if app and user:
       +        print("You are logged in to {} as {}\n".format(
       +            yellow(app.instance),
       +            yellow(user.username)
       +        ))
       +        print("User data: " + green(config.get_user_config_path()))
       +        print("App data:  " + green(config.get_instance_config_path(app.instance)))
       +    else:
       +        print("You are not logged in")
       +
       +
       +def login(app, user, args):
       +    app = create_app_interactive()
       +    login_interactive(app)
       +
       +    print()
       +    print(green("✓ Successfully logged in."))
       +
       +
       +def login_2fa(app, user, args):
       +    print()
       +    print(yellow("Two factor authentication is experimental."))
       +    print(yellow("If you have problems logging in, please open an issue:"))
       +    print(yellow("https://github.com/ihabunek/toot/issues"))
       +    print()
       +
       +    app = create_app_interactive()
       +    two_factor_login_interactive(app)
       +
       +    print()
       +    print(green("✓ Successfully logged in."))
       +
       +
       +def logout(app, user, args):
       +    config.delete_user()
       +
       +    print(green("✓ You are now logged out"))
       +
       +
       +def upload(app, user, args):
       +    response = _do_upload(app, user, args.file)
       +
       +    print("\nSuccessfully uploaded media ID {}, type '{}'".format(
       +         yellow(response['id']),  yellow(response['type'])))
       +    print("Original URL: " + green(response['url']))
       +    print("Preview URL:  " + green(response['preview_url']))
       +    print("Text URL:     " + green(response['text_url']))
       +
       +
       +def _print_accounts(accounts):
       +    if not accounts:
       +        return
       +
       +    print("\nAccounts:")
       +    for account in accounts:
       +        acct = green("@{}".format(account['acct']))
       +        display_name = account['display_name']
       +        print("* {} {}".format(acct, display_name))
       +
       +
       +def _print_hashtags(hashtags):
       +    if not hashtags:
       +        return
       +
       +    print("\nHashtags:")
       +    print(", ".join([green("#" + t) for t in hashtags]))
       +
       +
       +def search(app, user, args):
       +    response = api.search(app, user, args.query, args.resolve)
       +
       +    _print_accounts(response['accounts'])
       +    _print_hashtags(response['hashtags'])
       +
       +
       +def _do_upload(app, user, file):
       +    print("Uploading media: {}".format(green(file.name)))
       +    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 or "@" + account['acct'] == account_name:
       +            return account
       +
       +
       +def follow(app, user, 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("✓ You are now following %s" % args.account))
       +
       +
       +def unfollow(app, user, 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("✓ You are no longer following %s" % args.account))
       +
       +
       +def whoami(app, user, args):
       +    response = api.verify_credentials(app, user)
       +
       +    print("{} {}".format(green("@" + response['acct']), response['display_name']))
       +    print(response['note'])
       +    print(response['url'])
       +    print("")
       +    print("ID: " + green(response['id']))
       +    print("Since: " + green(response['created_at'][:19].replace('T', ' @ ')))
       +    print("")
       +    print("Followers: " + yellow(response['followers_count']))
       +    print("Following: " + yellow(response['following_count']))
       +    print("Statuses: " + yellow(response['statuses_count']))
 (DIR) diff --git a/toot/console.py b/toot/console.py
       @@ -2,479 +2,183 @@
        from __future__ import unicode_literals
        from __future__ import print_function
        
       -import json
       -import logging
        import os
       -import requests
        import sys
       +import logging
        
        from argparse import ArgumentParser, FileType
       -from bs4 import BeautifulSoup
       -from builtins import input
       -from datetime import datetime
       -from future.moves.itertools import zip_longest
       -from getpass import getpass
       -from itertools import chain
       -from textwrap import TextWrapper
       -
       -from toot import api, config, DEFAULT_INSTANCE, User, App
       -from toot.api import ApiError
       -
       -
       -class ConsoleError(Exception):
       -    pass
       -
       -
       -def red(text):
       -    return "\033[31m{}\033[0m".format(text)
       -
       -
       -def green(text):
       -    return "\033[32m{}\033[0m".format(text)
       -
       -
       -def yellow(text):
       -    return "\033[33m{}\033[0m".format(text)
       -
       -
       -def blue(text):
       -    return "\033[34m{}\033[0m".format(text)
       -
       -
       -def print_error(text):
       -    print(red(text), file=sys.stderr)
       -
       -
       -def register_app(instance):
       -    print("Registering application with %s" % green(instance))
       -
       -    try:
       -        response = api.create_app(instance)
       -    except:
       -        raise ConsoleError("Registration failed. Did you enter a valid instance?")
       -
       -    base_url = 'https://' + instance
       -
       -    app = App(instance, base_url, response['client_id'], response['client_secret'])
       -    path = config.save_app(app)
       -    print("Application tokens saved to: {}".format(green(path)))
       -
       -    return app
       -
       -
       -def create_app_interactive():
       -    instance = input("Choose an instance [%s]: " % green(DEFAULT_INSTANCE))
       -    if not instance:
       -        instance = DEFAULT_INSTANCE
       -
       -    return config.load_app(instance) or register_app(instance)
       -
       -
       -def login_interactive(app):
       -    print("\nLog in to " + green(app.instance))
       -    email = input('Email: ')
       -    password = getpass('Password: ')
       -
       -    if not email or not password:
       -        raise ConsoleError("Email and password cannot be empty.")
       -
       -    try:
       -        print("Authenticating...")
       -        response = api.login(app, email, password)
       -    except ApiError:
       -        raise ConsoleError("Login failed")
       -
       -    user = User(app.instance, email, response['access_token'])
       -    path = config.save_user(user)
       -    print("Access token saved to: " + green(path))
       -
       -    return user
       -
       -
       -def two_factor_login_interactive(app):
       -    """Hacky implementation of two factor authentication"""
       -
       -    print("Log in to " + green(app.instance))
       -    email = input('Email: ')
       -    password = getpass('Password: ')
       -
       -    sign_in_url = app.base_url + '/auth/sign_in'
       -
       -    session = requests.Session()
       +from collections import namedtuple
       +from toot import config, api, commands, ConsoleError, CLIENT_NAME, CLIENT_WEBSITE
       +from toot.output import print_error
        
       -    # Fetch sign in form
       -    response = session.get(sign_in_url)
       -    response.raise_for_status()
        
       -    soup = BeautifulSoup(response.content, "html.parser")
       -    form = soup.find('form')
       -    inputs = form.find_all('input')
       +VISIBILITY_CHOICES = ['public', 'unlisted', 'private', 'direct']
        
       -    data = {i.attrs.get('name'): i.attrs.get('value') for i in inputs}
       -    data['user[email]'] = email
       -    data['user[password]'] = password
        
       -    # Submit form, get 2FA entry form
       -    response = session.post(sign_in_url, data)
       -    response.raise_for_status()
       -
       -    soup = BeautifulSoup(response.content, "html.parser")
       -    form = soup.find('form')
       -    inputs = form.find_all('input')
       -
       -    data = {i.attrs.get('name'): i.attrs.get('value') for i in inputs}
       -    data['user[otp_attempt]'] = input("2FA Token: ")
       -
       -    # Submit token
       -    response = session.post(sign_in_url, data)
       -    response.raise_for_status()
       -
       -    # Extract access token from response
       -    soup = BeautifulSoup(response.content, "html.parser")
       -    initial_state = soup.find('script', id='initial-state')
       +def visibility(value):
       +    """Validates the visibilty parameter"""
       +    if value not in VISIBILITY_CHOICES:
       +        raise ValueError("Invalid visibility value")
        
       -    if not initial_state:
       -        raise ConsoleError("Login failed: Invalid 2FA token?")
       +    return value
        
       -    data = json.loads(initial_state.get_text())
       -    access_token = data['meta']['access_token']
        
       -    user = User(app.instance, email, access_token)
       -    path = config.save_user(user)
       -    print("Access token saved to: " + green(path))
       +Command = namedtuple("Command", ["name", "description", "require_auth", "arguments"])
       +
       +
       +COMMANDS = [
       +    Command(
       +        name="login",
       +        description="Log into a Mastodon instance",
       +        arguments=[],
       +        require_auth=False,
       +    ),
       +    Command(
       +        name="login_2fa",
       +        description="Log in using two factor authentication (experimental)",
       +        arguments=[],
       +        require_auth=False,
       +    ),
       +    Command(
       +        name="logout",
       +        description="Log out, delete stored access keys",
       +        arguments=[],
       +        require_auth=False,
       +    ),
       +    Command(
       +        name="auth",
       +        description="Show stored credentials",
       +        arguments=[],
       +        require_auth=False,
       +    ),
       +    Command(
       +        name="whoami",
       +        description="Display logged in user details",
       +        arguments=[],
       +        require_auth=True,
       +    ),
       +    Command(
       +        name="post",
       +        description="Post a status text to your timeline",
       +        arguments=[
       +            (["text"], {
       +                "help": "The status text to post.",
       +            }),
       +            (["-m", "--media"], {
       +                "type": FileType('rb'),
       +                "help": "path to the media file to attach"
       +            }),
       +            (["-v", "--visibility"], {
       +                "type": visibility,
       +                "default": "public",
       +                "help": 'post visibility, one of: %s' % ", ".join(VISIBILITY_CHOICES),
       +            })
       +        ],
       +        require_auth=True,
       +    ),
       +    Command(
       +        name="upload",
       +        description="Upload an image or video file",
       +        arguments=[
       +            (["file"], {
       +                "help": "Path to the file to upload",
       +                "type": FileType('rb')
       +            })
       +        ],
       +        require_auth=True,
       +    ),
       +    Command(
       +        name="search",
       +        description="Search for users or hashtags",
       +        arguments=[
       +            (["query"], {
       +                "help": "the search query",
       +            }),
       +            (["-r", "--resolve"], {
       +                "action": 'store_true',
       +                "default": False,
       +                "help": "Resolve non-local accounts",
       +            }),
       +        ],
       +        require_auth=True,
       +    ),
       +    Command(
       +        name="follow",
       +        description="Follow an account",
       +        arguments=[
       +            (["account"], {
       +                "help": "account name, e.g. 'Gargron' or 'polymerwitch@toot.cat'",
       +            }),
       +        ],
       +        require_auth=True,
       +    ),
       +    Command(
       +        name="unfollow",
       +        description="Unfollow an account",
       +        arguments=[
       +            (["account"], {
       +                "help": "account name, e.g. 'Gargron' or 'polymerwitch@toot.cat'",
       +            }),
       +        ],
       +        require_auth=True,
       +    ),
       +    Command(
       +        name="timeline",
       +        description="Show recent items in your public timeline",
       +        arguments=[],
       +        require_auth=True,
       +    ),
       +]
        
        
        def print_usage():
       -    print("toot - interact with Mastodon from the command line")
       +    print(CLIENT_NAME)
            print("")
            print("Usage:")
       -    print("  toot login      - log into a Mastodon instance")
       -    print("  toot 2fa        - log into a Mastodon instance using 2FA (experimental)")
       -    print("  toot logout     - log out (delete stored access tokens)")
       -    print("  toot auth       - display stored authentication tokens")
       -    print("  toot whoami     - display logged in user details")
       -    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")
       +
       +    max_name_len = max(len(command.name) for command in COMMANDS)
       +
       +    for command in COMMANDS:
       +        print("  toot", command.name.ljust(max_name_len + 2), command.description)
       +
            print("")
            print("To get help for each command run:")
            print("  toot <command> --help")
            print("")
       -    print("https://github.com/ihabunek/toot")
       -
       -
       -def print_timeline(item):
       -    def wrap_text(text, width):
       -        wrapper = TextWrapper(width=width, break_long_words=False, break_on_hyphens=False)
       -        return chain(*[wrapper.wrap(l) for l in text.split("\n")])
       -
       -    def timeline_rows(item):
       -        name = item['name']
       -        time = item['time'].strftime('%Y-%m-%d %H:%M%Z')
       -
       -        left_column = [name, time]
       -        if 'reblogged' in item:
       -            left_column.append(item['reblogged'])
       -
       -        text = item['text']
       -
       -        right_column = wrap_text(text, 80)
       -
       -        return zip_longest(left_column, right_column, fillvalue="")
       -
       -    for left, right in timeline_rows(item):
       -        print("{:30} │ {}".format(left, right))
       -
       -
       -def parse_timeline(item):
       -    content = item['reblog']['content'] if item['reblog'] else item['content']
       -    reblogged = item['reblog']['account']['username'] if item['reblog'] else ""
       -
       -    name = item['account']['display_name'] + " @" + item['account']['username']
       -    soup = BeautifulSoup(content, "html.parser")
       -    text = soup.get_text().replace('&apos;', "'")
       -    time = datetime.strptime(item['created_at'], "%Y-%m-%dT%H:%M:%S.%fZ")
       -
       -    return {
       -        "name": name,
       -        "text": text,
       -        "time": time,
       -        "reblogged": reblogged,
       -    }
       -
       -
       -def cmd_timeline(app, user, args):
       -    parser = ArgumentParser(prog="toot timeline",
       -                            description="Show recent items in your public timeline",
       -                            epilog="https://github.com/ihabunek/toot")
       -
       -    args = parser.parse_args(args)
       -
       -    items = api.timeline_home(app, user)
       -    parsed_items = [parse_timeline(t) for t in items]
       -
       -    print("─" * 31 + "┬" + "─" * 88)
       -    for item in parsed_items:
       -        print_timeline(item)
       -        print("─" * 31 + "┼" + "─" * 88)
       -
       -
       -def visibility(value):
       -    if value not in ['public', 'unlisted', 'private', 'direct']:
       -        raise ValueError("Invalid visibility value")
       -
       -    return value
       -
       -
       -def cmd_post_status(app, user, args):
       -    parser = ArgumentParser(prog="toot post",
       -                            description="Post a status text to the timeline",
       -                            epilog="https://github.com/ihabunek/toot")
       -    parser.add_argument("text", help="The status text to post.")
       -    parser.add_argument("-m", "--media", type=FileType('rb'),
       -                        help="path to the media file to attach")
       -    parser.add_argument("-v", "--visibility", type=visibility, default="public",
       -                        help='post visibility, either "public" (default), "direct", "private", or "unlisted"')
       -
       -    args = parser.parse_args(args)
       +    print(CLIENT_WEBSITE)
        
       -    if args.media:
       -        media = do_upload(app, user, args.media)
       -        media_ids = [media['id']]
       -    else:
       -        media_ids = None
        
       -    response = api.post_status(app, user, args.text, media_ids=media_ids, visibility=args.visibility)
       +def get_argument_parser(name, command):
       +    parser = ArgumentParser(
       +        prog='toot %s' % name,
       +        description=command.description,
       +        epilog=CLIENT_WEBSITE)
        
       -    print("Toot posted: " + green(response.get('url')))
       +    for args, kwargs in command.arguments:
       +        parser.add_argument(*args, **kwargs)
        
       +    return parser
        
       -def cmd_auth(app, user, args):
       -    parser = ArgumentParser(prog="toot auth",
       -                            description="Show login details",
       -                            epilog="https://github.com/ihabunek/toot")
       -    parser.parse_args(args)
        
       -    if app and user:
       -        print("You are logged in to {} as {}".format(green(app.instance), green(user.username)))
       -        print("User data: " + green(config.get_user_config_path()))
       -        print("App data:  " + green(config.get_instance_config_path(app.instance)))
       -    else:
       -        print("You are not logged in")
       +def run_command(app, user, name, args):
       +    command = next((c for c in COMMANDS if c.name == name), None)
        
       -
       -def cmd_login(args):
       -    parser = ArgumentParser(prog="toot login",
       -                            description="Log into a Mastodon instance",
       -                            epilog="https://github.com/ihabunek/toot")
       -    parser.parse_args(args)
       -
       -    app = create_app_interactive()
       -    user = login_interactive(app)
       -
       -    return app, user
       -
       -
       -def cmd_2fa(args):
       -    parser = ArgumentParser(prog="toot 2fa",
       -                            description="Log into a Mastodon instance using 2 factor authentication (experimental)",
       -                            epilog="https://github.com/ihabunek/toot")
       -    parser.parse_args(args)
       -
       -    print()
       -    print(yellow("Two factor authentication is experimental."))
       -    print(yellow("If you have problems logging in, please open an issue:"))
       -    print(yellow("https://github.com/ihabunek/toot/issues"))
       -    print()
       -
       -    app = create_app_interactive()
       -    user = two_factor_login_interactive(app)
       -
       -    return app, user
       -
       -
       -def cmd_logout(app, user, args):
       -    parser = ArgumentParser(prog="toot logout",
       -                            description="Log out, delete stored access keys",
       -                            epilog="https://github.com/ihabunek/toot")
       -    parser.parse_args(args)
       -
       -    config.delete_user()
       -
       -    print(green("✓ You are now logged out"))
       -
       -
       -def cmd_upload(app, user, args):
       -    parser = ArgumentParser(prog="toot upload",
       -                            description="Upload an image or video file",
       -                            epilog="https://github.com/ihabunek/toot")
       -    parser.add_argument("file", help="Path to the file to upload", type=FileType('rb'))
       -
       -    args = parser.parse_args(args)
       -
       -    response = do_upload(app, user, args.file)
       -
       -    print("\nSuccessfully uploaded media ID {}, type '{}'".format(
       -         yellow(response['id']),  yellow(response['type'])))
       -    print("Original URL: " + green(response['url']))
       -    print("Preview URL:  " + green(response['preview_url']))
       -    print("Text URL:     " + green(response['text_url']))
       -
       -
       -def _print_accounts(accounts):
       -    if not accounts:
       -        return
       -
       -    print("\nAccounts:")
       -    for account in accounts:
       -        acct = green("@{}".format(account['acct']))
       -        display_name = account['display_name']
       -        print("* {} {}".format(acct, display_name))
       -
       -
       -def _print_hashtags(hashtags):
       -    if not hashtags:
       -        return
       -
       -    print("\nHashtags:")
       -    print(", ".join([green("#" + t) for t in hashtags]))
       -
       -
       -def cmd_search(app, user, args):
       -    parser = ArgumentParser(prog="toot search",
       -                            description="Search for content",
       -                            epilog="https://github.com/ihabunek/toot")
       -
       -    parser.add_argument("query", help="The search query")
       -    parser.add_argument("-r", "--resolve", action='store_true', default=False,
       -                        help="Whether to resolve non-local accounts")
       -
       -    args = parser.parse_args(args)
       -
       -    response = api.search(app, user, args.query, args.resolve)
       -
       -    _print_accounts(response['accounts'])
       -    _print_hashtags(response['hashtags'])
       -
       -
       -def do_upload(app, user, file):
       -    print("Uploading media: {}".format(green(file.name)))
       -    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 or "@" + 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("✓ 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")
       +    if not command:
       +        print_error("Unknown command '{}'\n".format(name))
       +        print_usage()
                return
        
       -    api.unfollow(app, user, account['id'])
       -
       -    print(green("✓ You are no longer following %s" % args.account))
       -
       -
       -def cmd_whoami(app, user, args):
       -    parser = ArgumentParser(prog="toot whoami",
       -                            description="Display logged in user details",
       -                            epilog="https://github.com/ihabunek/toot")
       -    parser.parse_args(args)
       -
       -    response = api.verify_credentials(app, user)
       -
       -    print("{} {}".format(green("@" + response['acct']), response['display_name']))
       -    print(response['note'])
       -    print(response['url'])
       -    print("")
       -    print("ID: " + green(response['id']))
       -    print("Since: " + green(response['created_at'][:19].replace('T', ' @ ')))
       -    print("")
       -    print("Followers: " + yellow(response['followers_count']))
       -    print("Following: " + yellow(response['following_count']))
       -    print("Statuses: " + yellow(response['statuses_count']))
       -
       -
       -def run_command(command, args):
       -    user = config.load_user()
       -    app = config.load_app(user.instance) if user else None
       -
       -    # Commands which can run when not logged in
       -    if command == 'login':
       -        return cmd_login(args)
       +    parser = get_argument_parser(name, command)
       +    parsed_args = parser.parse_args(args)
        
       -    if command == '2fa':
       -        return cmd_2fa(args)
       -
       -    if command == 'auth':
       -        return cmd_auth(app, user, args)
       -
       -    # Commands which require user to be logged in
       -    if not app or not user:
       -        print_error("You are not logged in.")
       +    if command.require_auth and (not user or not app):
       +        print_error("This command requires that you are logged in.")
                print_error("Please run `toot login` first.")
                return
        
       -    if command == 'logout':
       -        return cmd_logout(app, user, args)
       -
       -    if command == 'post':
       -        return cmd_post_status(app, user, args)
       -
       -    if command == 'timeline':
       -        return cmd_timeline(app, user, args)
       +    fn = commands.__dict__.get(name)
        
       -    if command == 'upload':
       -        return cmd_upload(app, user, 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)
       -
       -    if command == 'whoami':
       -        return cmd_whoami(app, user, args)
       -
       -    print_error("Unknown command '{}'\n".format(command))
       -    print_usage()
       +    return fn(app, user, parsed_args)
        
        
        def main():
       @@ -485,15 +189,18 @@ def main():
            if not sys.stdin.isatty():
                sys.argv.append(sys.stdin.read())
        
       -    command = sys.argv[1] if len(sys.argv) > 1 else None
       +    command_name = sys.argv[1] if len(sys.argv) > 1 else None
            args = sys.argv[2:]
        
       -    if not command:
       +    if not command_name:
                return print_usage()
        
       +    user = config.load_user()
       +    app = config.load_app(user.instance) if user else None
       +
            try:
       -        run_command(command, args)
       +        run_command(app, user, command_name, args)
            except ConsoleError as e:
                print_error(str(e))
       -    except ApiError as e:
       +    except api.ApiError as e:
                print_error(str(e))
 (DIR) diff --git a/toot/output.py b/toot/output.py
       @@ -0,0 +1,37 @@
       +# -*- coding: utf-8 -*-
       +from __future__ import unicode_literals
       +from __future__ import print_function
       +
       +import sys
       +
       +
       +def _color(text, color):
       +    return "\033[3{}m{}\033[0m".format(color, text)
       +
       +
       +def red(text):
       +    return _color(text, 1)
       +
       +
       +def green(text):
       +    return _color(text, 2)
       +
       +
       +def yellow(text):
       +    return _color(text, 3)
       +
       +
       +def blue(text):
       +    return _color(text, 4)
       +
       +
       +def magenta(text):
       +    return _color(text, 5)
       +
       +
       +def cyan(text):
       +    return _color(text, 6)
       +
       +
       +def print_error(text):
       +    print(red(text), file=sys.stderr)