Rework how commands are defined

This commit is contained in:
Ivan Habunek 2017-04-19 14:47:30 +02:00
parent 2a3d66bae5
commit 373f26424d
No known key found for this signature in database
GPG Key ID: CDBD63C43A30BB95
7 changed files with 547 additions and 480 deletions

View File

@ -34,20 +34,30 @@ Running ``toot`` displays a list of available commands.
Running ``toot <command> -h`` shows the documentation for the given command. Running ``toot <command> -h`` shows the documentation for the given command.
=================== =============================================================== .. code-block::
Command Description
=================== =============================================================== $ toot
``toot login`` Log into a Mastodon instance.
``toot 2fa`` Log into a Mastodon instance using two factor authentication. toot - a Mastodon CLI client
``toot logout`` Log out, deletes stored access keys.
``toot auth`` Display stored authenitication tokens. Usage:
``toot whoami`` Display logged in user details. toot login Log into a Mastodon instance
``toot post`` Post a status to your timeline. toot login_2fa Log in using two factor authentication (experimental)
``toot search`` Search for accounts or hashtags. toot logout Log out, delete stored access keys
``toot timeline`` Display recent items in your public timeline. toot auth Show stored credentials
``toot follow`` Follow an account. toot whoami Display logged in user details
``toot unfollow`` Unfollow an account. 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 Authentication
-------------- --------------

View File

@ -19,10 +19,10 @@ def uncolorize(text):
def test_print_usage(capsys): def test_print_usage(capsys):
console.print_usage() console.print_usage()
out, err = capsys.readouterr() out, err = capsys.readouterr()
assert "toot - interact with Mastodon from the command line" in out assert "toot - a Mastodon CLI client" in out
def test_post_status_defaults(monkeypatch, capsys): def test_post_defaults(monkeypatch, capsys):
def mock_prepare(request): def mock_prepare(request):
assert request.method == 'POST' assert request.method == 'POST'
assert request.url == 'https://habunek.com/api/v1/statuses' 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.Request, 'prepare', mock_prepare)
monkeypatch.setattr(requests.Session, 'send', mock_send) 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() out, err = capsys.readouterr()
assert "Toot posted" in out assert "Toot posted" in out
def test_post_status_with_options(monkeypatch, capsys): def test_post_with_options(monkeypatch, capsys):
def mock_prepare(request): def mock_prepare(request):
assert request.method == 'POST' assert request.method == 'POST'
assert request.url == 'https://habunek.com/api/v1/statuses' 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'] args = ['"Hello world"', '--visibility', 'unlisted']
console.cmd_post_status(app, user, args) console.run_command(app, user, 'post', args)
out, err = capsys.readouterr() out, err = capsys.readouterr()
assert "Toot posted" in out 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'] args = ['Hello world', '--visibility', 'foo']
with pytest.raises(SystemExit): with pytest.raises(SystemExit):
console.cmd_post_status(app, user, args) console.run_command(app, user, 'post', args)
out, err = capsys.readouterr() out, err = capsys.readouterr()
assert "invalid visibility value: 'foo'" in err 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'] args = ['Hello world', '--media', 'does_not_exist.jpg']
with pytest.raises(SystemExit): with pytest.raises(SystemExit):
console.cmd_post_status(app, user, args) console.run_command(app, user, 'post', args)
out, err = capsys.readouterr() out, err = capsys.readouterr()
assert "can't open 'does_not_exist.jpg'" in err assert "can't open 'does_not_exist.jpg'" in err
@ -112,7 +112,7 @@ def test_timeline(monkeypatch, capsys):
monkeypatch.setattr(requests, 'get', mock_get) monkeypatch.setattr(requests, 'get', mock_get)
console.cmd_timeline(app, user, []) console.run_command(app, user, 'timeline', [])
out, err = capsys.readouterr() out, err = capsys.readouterr()
assert "The computer can't tell you the emotional story." in out assert "The computer can't tell you the emotional story." in out
@ -138,7 +138,7 @@ def test_upload(monkeypatch, capsys):
monkeypatch.setattr(requests.Request, 'prepare', mock_prepare) monkeypatch.setattr(requests.Request, 'prepare', mock_prepare)
monkeypatch.setattr(requests.Session, 'send', mock_send) monkeypatch.setattr(requests.Session, 'send', mock_send)
console.cmd_upload(app, user, [__file__]) console.run_command(app, user, 'upload', [__file__])
out, err = capsys.readouterr() out, err = capsys.readouterr()
assert "Uploading media" in out assert "Uploading media" in out
@ -168,7 +168,7 @@ def test_search(monkeypatch, capsys):
monkeypatch.setattr(requests, 'get', mock_get) monkeypatch.setattr(requests, 'get', mock_get)
console.cmd_search(app, user, ['freddy']) console.run_command(app, user, 'search', ['freddy'])
out, err = capsys.readouterr() out, err = capsys.readouterr()
assert "Hashtags:\n\033[32m#foo\033[0m, \033[32m#bar\033[0m, \033[32m#baz\033[0m" in out assert "Hashtags:\n\033[32m#foo\033[0m, \033[32m#bar\033[0m, \033[32m#baz\033[0m" in out
@ -200,7 +200,7 @@ def test_follow(monkeypatch, capsys):
monkeypatch.setattr(requests.Session, 'send', mock_send) monkeypatch.setattr(requests.Session, 'send', mock_send)
monkeypatch.setattr(requests, 'get', mock_get) monkeypatch.setattr(requests, 'get', mock_get)
console.cmd_follow(app, user, ['blixa']) console.run_command(app, user, 'follow', ['blixa'])
out, err = capsys.readouterr() out, err = capsys.readouterr()
assert "You are now following blixa" in out 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) monkeypatch.setattr(requests, 'get', mock_get)
console.cmd_follow(app, user, ['blixa']) console.run_command(app, user, 'follow', ['blixa'])
out, err = capsys.readouterr() out, err = capsys.readouterr()
assert "Account not found" in err 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.Session, 'send', mock_send)
monkeypatch.setattr(requests, 'get', mock_get) monkeypatch.setattr(requests, 'get', mock_get)
console.cmd_unfollow(app, user, ['blixa']) console.run_command(app, user, 'unfollow', ['blixa'])
out, err = capsys.readouterr() out, err = capsys.readouterr()
assert "You are no longer following blixa" in out 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) monkeypatch.setattr(requests, 'get', mock_get)
console.cmd_unfollow(app, user, ['blixa']) console.run_command(app, user, 'unfollow', ['blixa'])
out, err = capsys.readouterr() out, err = capsys.readouterr()
assert "Account not found" in err assert "Account not found" in err
@ -297,7 +297,7 @@ def test_whoami(monkeypatch, capsys):
monkeypatch.setattr(requests, 'get', mock_get) monkeypatch.setattr(requests, 'get', mock_get)
console.cmd_whoami(app, user, []) console.run_command(app, user, 'whoami', [])
out, err = capsys.readouterr() out, err = capsys.readouterr()
out = uncolorize(out) out = uncolorize(out)

View File

@ -1,4 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals
from __future__ import print_function
from collections import namedtuple from collections import namedtuple
@ -7,5 +9,9 @@ User = namedtuple('User', ['instance', 'username', 'access_token'])
DEFAULT_INSTANCE = 'mastodon.social' DEFAULT_INSTANCE = 'mastodon.social'
CLIENT_NAME = 'toot - Mastodon CLI Interface' CLIENT_NAME = 'toot - a Mastodon CLI client'
CLIENT_WEBSITE = 'https://github.com/ihabunek/toot' CLIENT_WEBSITE = 'https://github.com/ihabunek/toot'
class ConsoleError(Exception):
pass

View File

@ -5,7 +5,7 @@ import requests
from requests import Request, Session 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' SCOPES = 'read write follow'

307
toot/commands.py Normal file
View File

@ -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']))

View File

@ -2,479 +2,183 @@
from __future__ import unicode_literals from __future__ import unicode_literals
from __future__ import print_function from __future__ import print_function
import json
import logging
import os import os
import requests
import sys import sys
import logging
from argparse import ArgumentParser, FileType from argparse import ArgumentParser, FileType
from bs4 import BeautifulSoup from collections import namedtuple
from builtins import input from toot import config, api, commands, ConsoleError, CLIENT_NAME, CLIENT_WEBSITE
from datetime import datetime from toot.output import print_error
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): VISIBILITY_CHOICES = ['public', 'unlisted', 'private', 'direct']
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 <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): 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") raise ValueError("Invalid visibility value")
return value return value
def cmd_post_status(app, user, args): Command = namedtuple("Command", ["name", "description", "require_auth", "arguments"])
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')))
def cmd_auth(app, user, args): COMMANDS = [
parser = ArgumentParser(prog="toot auth", Command(
description="Show login details", name="login",
epilog="https://github.com/ihabunek/toot") description="Log into a Mastodon instance",
parser.parse_args(args) arguments=[],
require_auth=False,
if app and user: ),
print("You are logged in to {} as {}".format(green(app.instance), green(user.username))) Command(
print("User data: " + green(config.get_user_config_path())) name="login_2fa",
print("App data: " + green(config.get_instance_config_path(app.instance))) description="Log in using two factor authentication (experimental)",
else: arguments=[],
print("You are not logged in") 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): def print_usage():
parser = ArgumentParser(prog="toot login", print(CLIENT_NAME)
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'])
print("") print("")
print("ID: " + green(response['id'])) print("Usage:")
print("Since: " + green(response['created_at'][:19].replace('T', ' @ ')))
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("")
print("Followers: " + yellow(response['followers_count'])) print("To get help for each command run:")
print("Following: " + yellow(response['following_count'])) print(" toot <command> --help")
print("Statuses: " + yellow(response['statuses_count'])) print("")
print(CLIENT_WEBSITE)
def run_command(command, args): def get_argument_parser(name, command):
user = config.load_user() parser = ArgumentParser(
app = config.load_app(user.instance) if user else None prog='toot %s' % name,
description=command.description,
epilog=CLIENT_WEBSITE)
# Commands which can run when not logged in for args, kwargs in command.arguments:
if command == 'login': parser.add_argument(*args, **kwargs)
return cmd_login(args)
if command == '2fa': return parser
return cmd_2fa(args)
if command == 'auth':
return cmd_auth(app, user, args)
# Commands which require user to be logged in def run_command(app, user, name, args):
if not app or not user: command = next((c for c in COMMANDS if c.name == name), None)
print_error("You are not logged in.")
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.") print_error("Please run `toot login` first.")
return return
if command == 'logout': fn = commands.__dict__.get(name)
return cmd_logout(app, user, args)
if command == 'post': return fn(app, user, parsed_args)
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()
def main(): def main():
@ -485,15 +189,18 @@ def main():
if not sys.stdin.isatty(): if not sys.stdin.isatty():
sys.argv.append(sys.stdin.read()) 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:] args = sys.argv[2:]
if not command: if not command_name:
return print_usage() return print_usage()
user = config.load_user()
app = config.load_app(user.instance) if user else None
try: try:
run_command(command, args) run_command(app, user, command_name, args)
except ConsoleError as e: except ConsoleError as e:
print_error(str(e)) print_error(str(e))
except ApiError as e: except api.ApiError as e:
print_error(str(e)) print_error(str(e))

37
toot/output.py Normal file
View File

@ -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)