From 373f26424d002b22c8fa7fe7eabc38e13275d1fc Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Wed, 19 Apr 2017 14:47:30 +0200 Subject: [PATCH] Rework how commands are defined --- README.rst | 38 ++- tests/test_console.py | 34 +-- toot/__init__.py | 8 +- toot/api.py | 2 +- toot/commands.py | 307 +++++++++++++++++++++ toot/console.py | 601 +++++++++++------------------------------- toot/output.py | 37 +++ 7 files changed, 547 insertions(+), 480 deletions(-) create mode 100644 toot/commands.py create mode 100644 toot/output.py diff --git a/README.rst b/README.rst index 7f01568..7a98ec3 100644 --- a/README.rst +++ b/README.rst @@ -34,20 +34,30 @@ Running ``toot`` displays a list of available commands. Running ``toot -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 --help + + https://github.com/ihabunek/toot + Authentication -------------- diff --git a/tests/test_console.py b/tests/test_console.py index 1d9644a..6d19d7c 100644 --- 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) diff --git a/toot/__init__.py b/toot/__init__.py index edf574d..4e55f4d 100644 --- 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 diff --git a/toot/api.py b/toot/api.py index c614c80..6803029 100644 --- 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' diff --git a/toot/commands.py b/toot/commands.py new file mode 100644 index 0000000..cb15be9 --- /dev/null +++ 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(''', "'") + 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'])) diff --git a/toot/console.py b/toot/console.py index 8ee5bda..2bcf97b 100644 --- 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 +from collections import namedtuple +from toot import config, api, commands, ConsoleError, CLIENT_NAME, CLIENT_WEBSITE +from toot.output import print_error -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() - - # 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_usage(): - print("toot - interact with Mastodon from the command line") - 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") - print("") - print("To get help for each command run:") - print(" toot --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(''', "'") - 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) +VISIBILITY_CHOICES = ['public', 'unlisted', 'private', 'direct'] def visibility(value): - if value not in ['public', 'unlisted', 'private', 'direct']: + """Validates the visibilty parameter""" + if value not in VISIBILITY_CHOICES: 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) - - 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'))) +Command = namedtuple("Command", ["name", "description", "require_auth", "arguments"]) -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") +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 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") - 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']) +def print_usage(): + print(CLIENT_NAME) print("") - print("ID: " + green(response['id'])) - print("Since: " + green(response['created_at'][:19].replace('T', ' @ '))) + print("Usage:") + + 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("Followers: " + yellow(response['followers_count'])) - print("Following: " + yellow(response['following_count'])) - print("Statuses: " + yellow(response['statuses_count'])) + print("To get help for each command run:") + print(" toot --help") + print("") + print(CLIENT_WEBSITE) -def run_command(command, args): - user = config.load_user() - app = config.load_app(user.instance) if user else None +def get_argument_parser(name, command): + parser = ArgumentParser( + prog='toot %s' % name, + description=command.description, + epilog=CLIENT_WEBSITE) - # Commands which can run when not logged in - if command == 'login': - return cmd_login(args) + for args, kwargs in command.arguments: + parser.add_argument(*args, **kwargs) - if command == '2fa': - return cmd_2fa(args) + return parser - 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.") +def run_command(app, user, name, args): + command = next((c for c in COMMANDS if c.name == name), None) + + if not command: + print_error("Unknown command '{}'\n".format(name)) + print_usage() + return + + parser = get_argument_parser(name, command) + parsed_args = parser.parse_args(args) + + 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) + fn = commands.__dict__.get(name) - if command == 'post': - return cmd_post_status(app, user, args) - - if command == 'timeline': - return cmd_timeline(app, user, args) - - 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)) diff --git a/toot/output.py b/toot/output.py new file mode 100644 index 0000000..2c1da69 --- /dev/null +++ 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)