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.
=================== ===============================================================
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
--------------

View File

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

View File

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

View File

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

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

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)