`_.
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1630178600.2211607
toot-0.28.0/setup.cfg 0000644 0001750 0001750 00000000046 00000000000 014546 0 ustar 00ihabunek ihabunek [egg_info]
tag_build =
tag_date = 0
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1630178462.0
toot-0.28.0/setup.py 0000644 0001750 0001750 00000002777 00000000000 014454 0 ustar 00ihabunek ihabunek #!/usr/bin/env python
from setuptools import setup
long_description = """
Toot is a CLI and TUI tool for interacting with Mastodon instances from the
command line.
Allows posting text and media to the timeline, searching, following, muting
and blocking accounts and other actions.
"""
setup(
name='toot',
version='0.28.0',
description='Mastodon CLI client',
long_description=long_description.strip(),
author='Ivan Habunek',
author_email='ivan@habunek.com',
url='https://github.com/ihabunek/toot/',
project_urls={
'Documentation': 'https://toot.readthedocs.io/en/latest/',
'Issue tracker': 'https://github.com/ihabunek/toot/issues/',
},
keywords='mastodon toot',
license='GPLv3',
classifiers=[
'Development Status :: 4 - Beta',
'Environment :: Console :: Curses',
'Environment :: Console',
'License :: OSI Approved :: GNU General Public License v3 (GPLv3)',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3.7',
],
packages=['toot', 'toot.tui'],
python_requires=">=3.4",
install_requires=[
"requests>=2.13,<3.0",
"beautifulsoup4>=4.5.0,<5.0",
"wcwidth>=0.1.7,<2.0",
"urwid>=2.0.0,<3.0",
],
entry_points={
'console_scripts': [
'toot=toot.console:main',
],
}
)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1630178600.2211607
toot-0.28.0/tests/ 0000755 0001750 0001750 00000000000 00000000000 014067 5 ustar 00ihabunek ihabunek ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1577956384.0
toot-0.28.0/tests/__init__.py 0000644 0001750 0001750 00000000000 00000000000 016166 0 ustar 00ihabunek ihabunek ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1577956384.0
toot-0.28.0/tests/test_api.py 0000644 0001750 0001750 00000003703 00000000000 016254 0 ustar 00ihabunek ihabunek # -*- coding: utf-8 -*-
import pytest
from unittest import mock
from toot import App, CLIENT_NAME, CLIENT_WEBSITE
from toot.api import create_app, login, SCOPES, AuthenticationError
from tests.utils import MockResponse
@mock.patch('toot.http.anon_post')
def test_create_app(mock_post):
mock_post.return_value = MockResponse({
'client_id': 'foo',
'client_secret': 'bar',
})
create_app('bigfish.software')
mock_post.assert_called_once_with('https://bigfish.software/api/v1/apps', {
'website': CLIENT_WEBSITE,
'client_name': CLIENT_NAME,
'scopes': SCOPES,
'redirect_uris': 'urn:ietf:wg:oauth:2.0:oob',
})
@mock.patch('toot.http.anon_post')
def test_login(mock_post):
app = App('bigfish.software', 'https://bigfish.software', 'foo', 'bar')
data = {
'grant_type': 'password',
'client_id': app.client_id,
'client_secret': app.client_secret,
'username': 'user',
'password': 'pass',
'scope': SCOPES,
}
mock_post.return_value = MockResponse({
'token_type': 'bearer',
'scope': 'read write follow',
'access_token': 'xxx',
'created_at': 1492523699
})
login(app, 'user', 'pass')
mock_post.assert_called_once_with(
'https://bigfish.software/oauth/token', data, allow_redirects=False)
@mock.patch('toot.http.anon_post')
def test_login_failed(mock_post):
app = App('bigfish.software', 'https://bigfish.software', 'foo', 'bar')
data = {
'grant_type': 'password',
'client_id': app.client_id,
'client_secret': app.client_secret,
'username': 'user',
'password': 'pass',
'scope': SCOPES,
}
mock_post.return_value = MockResponse(is_redirect=True)
with pytest.raises(AuthenticationError):
login(app, 'user', 'pass')
mock_post.assert_called_once_with(
'https://bigfish.software/oauth/token', data, allow_redirects=False)
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1577956384.0
toot-0.28.0/tests/test_auth.py 0000644 0001750 0001750 00000003502 00000000000 016441 0 ustar 00ihabunek ihabunek # -*- coding: utf-8 -*-
from toot import App, User, api, config, auth
from tests.utils import retval
def test_register_app(monkeypatch):
app_data = {'id': 100, 'client_id': 'cid', 'client_secret': 'cs'}
def assert_app(app):
assert isinstance(app, App)
assert app.instance == "foo.bar"
assert app.base_url == "https://foo.bar"
assert app.client_id == "cid"
assert app.client_secret == "cs"
monkeypatch.setattr(api, 'create_app', retval(app_data))
monkeypatch.setattr(api, 'get_instance', retval({"title": "foo", "version": "1"}))
monkeypatch.setattr(config, 'save_app', assert_app)
app = auth.register_app("foo.bar")
assert_app(app)
def test_create_app_from_config(monkeypatch):
"""When there is saved config, it's returned"""
monkeypatch.setattr(config, 'load_app', retval("loaded app"))
app = auth.create_app_interactive("bezdomni.net")
assert app == 'loaded app'
def test_create_app_registered(monkeypatch):
"""When there is no saved config, a new app is registered"""
monkeypatch.setattr(config, 'load_app', retval(None))
monkeypatch.setattr(auth, 'register_app', retval("registered app"))
app = auth.create_app_interactive("bezdomni.net")
assert app == 'registered app'
def test_create_user(monkeypatch):
app = App(4, 5, 6, 7)
def assert_user(user, activate=True):
assert activate
assert isinstance(user, User)
assert user.instance == app.instance
assert user.username == "foo"
assert user.access_token == "abc"
monkeypatch.setattr(config, 'save_user', assert_user)
monkeypatch.setattr(api, 'verify_credentials', lambda x, y: {"username": "foo"})
user = auth.create_user(app, 'abc')
assert_user(user)
#
# TODO: figure out how to mock input so the rest can be tested
#
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1577956384.0
toot-0.28.0/tests/test_config.py 0000644 0001750 0001750 00000011473 00000000000 016753 0 ustar 00ihabunek ihabunek import os
import pytest
from toot import User, App, config
@pytest.fixture
def sample_config():
return {
'apps': {
'foo.social': {
'base_url': 'https://foo.social',
'client_id': 'abc',
'client_secret': 'def',
'instance': 'foo.social'
},
'bar.social': {
'base_url': 'https://bar.social',
'client_id': 'ghi',
'client_secret': 'jkl',
'instance': 'bar.social'
},
},
'users': {
'foo@bar.social': {
'access_token': 'mno',
'instance': 'bar.social',
'username': 'ihabunek'
}
},
'active_user': 'foo@bar.social',
}
def test_extract_active_user_app(sample_config):
user, app = config.extract_user_app(sample_config, sample_config['active_user'])
assert isinstance(user, User)
assert user.instance == 'bar.social'
assert user.username == 'ihabunek'
assert user.access_token == 'mno'
assert isinstance(app, App)
assert app.instance == 'bar.social'
assert app.base_url == 'https://bar.social'
assert app.client_id == 'ghi'
assert app.client_secret == 'jkl'
def test_extract_active_when_no_active_user(sample_config):
# When there is no active user
assert config.extract_user_app(sample_config, None) == (None, None)
# When active user does not exist for whatever reason
assert config.extract_user_app(sample_config, 'does-not-exist') == (None, None)
# When active app does not exist for whatever reason
sample_config['users']['foo@bar.social']['instance'] = 'does-not-exist'
assert config.extract_user_app(sample_config, 'foo@bar.social') == (None, None)
def test_save_app(sample_config):
app = App('xxx.yyy', 2, 3, 4)
app2 = App('moo.foo', 5, 6, 7)
app_count = len(sample_config['apps'])
assert 'xxx.yyy' not in sample_config['apps']
assert 'moo.foo' not in sample_config['apps']
# Sets
config.save_app.__wrapped__(sample_config, app)
assert len(sample_config['apps']) == app_count + 1
assert 'xxx.yyy' in sample_config['apps']
assert sample_config['apps']['xxx.yyy']['instance'] == 'xxx.yyy'
assert sample_config['apps']['xxx.yyy']['base_url'] == 2
assert sample_config['apps']['xxx.yyy']['client_id'] == 3
assert sample_config['apps']['xxx.yyy']['client_secret'] == 4
# Overwrites
config.save_app.__wrapped__(sample_config, app2)
assert len(sample_config['apps']) == app_count + 2
assert 'xxx.yyy' in sample_config['apps']
assert 'moo.foo' in sample_config['apps']
assert sample_config['apps']['xxx.yyy']['instance'] == 'xxx.yyy'
assert sample_config['apps']['xxx.yyy']['base_url'] == 2
assert sample_config['apps']['xxx.yyy']['client_id'] == 3
assert sample_config['apps']['xxx.yyy']['client_secret'] == 4
assert sample_config['apps']['moo.foo']['instance'] == 'moo.foo'
assert sample_config['apps']['moo.foo']['base_url'] == 5
assert sample_config['apps']['moo.foo']['client_id'] == 6
assert sample_config['apps']['moo.foo']['client_secret'] == 7
# Idempotent
config.save_app.__wrapped__(sample_config, app2)
assert len(sample_config['apps']) == app_count + 2
assert 'xxx.yyy' in sample_config['apps']
assert 'moo.foo' in sample_config['apps']
assert sample_config['apps']['xxx.yyy']['instance'] == 'xxx.yyy'
assert sample_config['apps']['xxx.yyy']['base_url'] == 2
assert sample_config['apps']['xxx.yyy']['client_id'] == 3
assert sample_config['apps']['xxx.yyy']['client_secret'] == 4
assert sample_config['apps']['moo.foo']['instance'] == 'moo.foo'
assert sample_config['apps']['moo.foo']['base_url'] == 5
assert sample_config['apps']['moo.foo']['client_id'] == 6
assert sample_config['apps']['moo.foo']['client_secret'] == 7
def test_delete_app(sample_config):
app = App('foo.social', 2, 3, 4)
app_count = len(sample_config['apps'])
assert 'foo.social' in sample_config['apps']
config.delete_app.__wrapped__(sample_config, app)
assert 'foo.social' not in sample_config['apps']
assert len(sample_config['apps']) == app_count - 1
# Idempotent
config.delete_app.__wrapped__(sample_config, app)
assert 'foo.social' not in sample_config['apps']
assert len(sample_config['apps']) == app_count - 1
def test_get_config_file_path():
fn = config.get_config_file_path
os.unsetenv('XDG_CONFIG_HOME')
os.environ.pop('XDG_CONFIG_HOME', None)
assert fn() == os.path.expanduser('~/.config/toot/config.json')
os.environ['XDG_CONFIG_HOME'] = '/foo/bar/config'
assert fn() == '/foo/bar/config/toot/config.json'
os.environ['XDG_CONFIG_HOME'] = '~/foo/config'
assert fn() == os.path.expanduser('~/foo/config/toot/config.json')
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1630178109.0
toot-0.28.0/tests/test_console.py 0000644 0001750 0001750 00000047260 00000000000 017153 0 ustar 00ihabunek ihabunek # -*- coding: utf-8 -*-
import io
import pytest
import re
from collections import namedtuple
from unittest import mock
from toot import console, User, App, http
from toot.exceptions import ConsoleError
from tests.utils import MockResponse
app = App('habunek.com', 'https://habunek.com', 'foo', 'bar')
user = User('habunek.com', 'ivan@habunek.com', 'xxx')
MockUuid = namedtuple("MockUuid", ["hex"])
def uncolorize(text):
"""Remove ANSI color sequences from a string"""
return re.sub(r'\x1b[^m]*m', '', text)
def test_print_usage(capsys):
console.print_usage()
out, err = capsys.readouterr()
assert "toot - a Mastodon CLI client" in out
@mock.patch('uuid.uuid4')
@mock.patch('toot.http.post')
def test_post_defaults(mock_post, mock_uuid, capsys):
mock_uuid.return_value = MockUuid("rock-on")
mock_post.return_value = MockResponse({
'url': 'https://habunek.com/@ihabunek/1234567890'
})
console.run_command(app, user, 'post', ['Hello world'])
mock_post.assert_called_once_with(app, user, '/api/v1/statuses', {
'status': 'Hello world',
'visibility': 'public',
'media_ids[]': [],
'sensitive': "false",
'spoiler_text': None,
'in_reply_to_id': None,
'language': None,
'scheduled_at': None,
}, headers={"Idempotency-Key": "rock-on"})
out, err = capsys.readouterr()
assert 'Toot posted' in out
assert 'https://habunek.com/@ihabunek/1234567890' in out
assert not err
@mock.patch('uuid.uuid4')
@mock.patch('toot.http.post')
def test_post_with_options(mock_post, mock_uuid, capsys):
mock_uuid.return_value = MockUuid("up-the-irons")
args = [
'Hello world',
'--visibility', 'unlisted',
'--sensitive',
'--spoiler-text', 'Spoiler!',
'--reply-to', '123a',
'--language', 'hrv',
]
mock_post.return_value = MockResponse({
'url': 'https://habunek.com/@ihabunek/1234567890'
})
console.run_command(app, user, 'post', args)
mock_post.assert_called_once_with(app, user, '/api/v1/statuses', {
'status': 'Hello world',
'media_ids[]': [],
'visibility': 'unlisted',
'sensitive': "true",
'spoiler_text': "Spoiler!",
'in_reply_to_id': '123a',
'language': 'hrv',
'scheduled_at': None,
}, headers={"Idempotency-Key": "up-the-irons"})
out, err = capsys.readouterr()
assert 'Toot posted' in out
assert 'https://habunek.com/@ihabunek/1234567890' in out
assert not err
def test_post_invalid_visibility(capsys):
args = ['Hello world', '--visibility', 'foo']
with pytest.raises(SystemExit):
console.run_command(app, user, 'post', args)
out, err = capsys.readouterr()
assert "invalid visibility value: 'foo'" in err
def test_post_invalid_media(capsys):
args = ['Hello world', '--media', 'does_not_exist.jpg']
with pytest.raises(SystemExit):
console.run_command(app, user, 'post', args)
out, err = capsys.readouterr()
assert "can't open 'does_not_exist.jpg'" in err
@mock.patch('toot.http.delete')
def test_delete(mock_delete, capsys):
console.run_command(app, user, 'delete', ['12321'])
mock_delete.assert_called_once_with(app, user, '/api/v1/statuses/12321')
out, err = capsys.readouterr()
assert 'Status deleted' in out
assert not err
@mock.patch('toot.http.get')
def test_timeline(mock_get, monkeypatch, capsys):
mock_get.return_value = MockResponse([{
'id': '111111111111111111',
'account': {
'display_name': 'Frank Zappa 🎸',
'acct': 'fz'
},
'created_at': '2017-04-12T15:53:18.174Z',
'content': "The computer can't tell you the emotional story. It can give you the exact mathematical design, but what's missing is the eyebrows.
",
'reblog': None,
'in_reply_to_id': None,
'media_attachments': [],
}])
console.run_command(app, user, 'timeline', ['--once'])
mock_get.assert_called_once_with(app, user, '/api/v1/timelines/home?limit=10', None)
out, err = capsys.readouterr()
lines = out.split("\n")
assert "Frank Zappa 🎸" in lines[1]
assert "@fz" in lines[1]
assert "2017-04-12 15:53" in lines[1]
assert (
"The computer can't tell you the emotional story. It can give you the "
"exact mathematical design, but\nwhat's missing is the eyebrows." in out)
assert "111111111111111111" in lines[-3]
assert err == ""
@mock.patch('toot.http.get')
def test_timeline_with_re(mock_get, monkeypatch, capsys):
mock_get.return_value = MockResponse([{
'id': '111111111111111111',
'created_at': '2017-04-12T15:53:18.174Z',
'account': {
'display_name': 'Frank Zappa',
'acct': 'fz'
},
'reblog': {
'account': {
'display_name': 'Johnny Cash',
'acct': 'jc'
},
'content': "The computer can't tell you the emotional story. It can give you the exact mathematical design, but what's missing is the eyebrows.
",
'media_attachments': [],
},
'in_reply_to_id': '111111111111111110',
'media_attachments': [],
}])
console.run_command(app, user, 'timeline', ['--once'])
mock_get.assert_called_once_with(app, user, '/api/v1/timelines/home?limit=10', None)
out, err = capsys.readouterr()
lines = out.split("\n")
assert "Frank Zappa" in lines[1]
assert "@fz" in lines[1]
assert "2017-04-12 15:53" in lines[1]
assert (
"The computer can't tell you the emotional story. It can give you the "
"exact mathematical design, but\nwhat's missing is the eyebrows." in out)
assert "111111111111111111" in lines[-3]
assert "↻ Reblogged @jc" in lines[-3]
assert err == ""
@mock.patch('toot.http.get')
def test_thread(mock_get, monkeypatch, capsys):
mock_get.side_effect = [
MockResponse({
'id': '111111111111111111',
'account': {
'display_name': 'Frank Zappa',
'acct': 'fz'
},
'created_at': '2017-04-12T15:53:18.174Z',
'content': "my response in the middle",
'reblog': None,
'in_reply_to_id': '111111111111111110',
'media_attachments': [],
}),
MockResponse({
'ancestors': [{
'id': '111111111111111110',
'account': {
'display_name': 'Frank Zappa',
'acct': 'fz'
},
'created_at': '2017-04-12T15:53:18.174Z',
'content': "original content",
'media_attachments': [],
'reblog': None,
'in_reply_to_id': None}],
'descendants': [{
'id': '111111111111111112',
'account': {
'display_name': 'Frank Zappa',
'acct': 'fz'
},
'created_at': '2017-04-12T15:53:18.174Z',
'content': "response message",
'media_attachments': [],
'reblog': None,
'in_reply_to_id': '111111111111111111'}],
}),
]
console.run_command(app, user, 'thread', ['111111111111111111'])
calls = [
mock.call(app, user, '/api/v1/statuses/111111111111111111'),
mock.call(app, user, '/api/v1/statuses/111111111111111111/context'),
]
mock_get.assert_has_calls(calls, any_order=False)
out, err = capsys.readouterr()
assert not err
# Display order
assert out.index('original content') < out.index('my response in the middle')
assert out.index('my response in the middle') < out.index('response message')
assert "original content" in out
assert "my response in the middle" in out
assert "response message" in out
assert "Frank Zappa" in out
assert "@fz" in out
assert "111111111111111111" in out
assert "In reply to" in out
@mock.patch('toot.http.get')
def test_reblogged_by(mock_get, monkeypatch, capsys):
mock_get.return_value = MockResponse([{
'display_name': 'Terry Bozzio',
'acct': 'bozzio@drummers.social',
}, {
'display_name': 'Dweezil',
'acct': 'dweezil@zappafamily.social',
}])
console.run_command(app, user, 'reblogged_by', ['111111111111111111'])
calls = [
mock.call(app, user, '/api/v1/statuses/111111111111111111/reblogged_by'),
]
mock_get.assert_has_calls(calls, any_order=False)
out, err = capsys.readouterr()
# Display order
expected = "\n".join([
"Terry Bozzio",
" @bozzio@drummers.social",
"Dweezil",
" @dweezil@zappafamily.social",
"",
])
assert out == expected
@mock.patch('toot.http.post')
def test_upload(mock_post, capsys):
mock_post.return_value = MockResponse({
'id': 123,
'url': 'https://bigfish.software/123/456',
'preview_url': 'https://bigfish.software/789/012',
'text_url': 'https://bigfish.software/345/678',
'type': 'image',
})
console.run_command(app, user, 'upload', [__file__])
mock_post.call_count == 1
args, kwargs = http.post.call_args
assert args == (app, user, '/api/v1/media')
assert isinstance(kwargs['files']['file'], io.BufferedReader)
out, err = capsys.readouterr()
assert "Uploading media" in out
assert __file__ in out
@mock.patch('toot.http.get')
def test_search(mock_get, capsys):
mock_get.return_value = MockResponse({
'hashtags': [
{
'history': [],
'name': 'foo',
'url': 'https://mastodon.social/tags/foo'
},
{
'history': [],
'name': 'bar',
'url': 'https://mastodon.social/tags/bar'
},
{
'history': [],
'name': 'baz',
'url': 'https://mastodon.social/tags/baz'
},
],
'accounts': [{
'acct': 'thequeen',
'display_name': 'Freddy Mercury'
}, {
'acct': 'thequeen@other.instance',
'display_name': 'Mercury Freddy'
}],
'statuses': [],
})
console.run_command(app, user, 'search', ['freddy'])
mock_get.assert_called_once_with(app, user, '/api/v2/search', {
'q': 'freddy',
'resolve': False,
})
out, err = capsys.readouterr()
assert "Hashtags:\n#foo, #bar, #baz" in out
assert "Accounts:" in out
assert "@thequeen Freddy Mercury" in out
assert "@thequeen@other.instance Mercury Freddy" in out
@mock.patch('toot.http.post')
@mock.patch('toot.http.get')
def test_follow(mock_get, mock_post, capsys):
mock_get.return_value = MockResponse([
{'id': 123, 'acct': 'blixa@other.acc'},
{'id': 321, 'acct': 'blixa'},
])
mock_post.return_value = MockResponse()
console.run_command(app, user, 'follow', ['blixa'])
mock_get.assert_called_once_with(app, user, '/api/v1/accounts/search', {'q': 'blixa'})
mock_post.assert_called_once_with(app, user, '/api/v1/accounts/321/follow')
out, err = capsys.readouterr()
assert "You are now following blixa" in out
@mock.patch('toot.http.get')
def test_follow_not_found(mock_get, capsys):
mock_get.return_value = MockResponse()
with pytest.raises(ConsoleError) as ex:
console.run_command(app, user, 'follow', ['blixa'])
mock_get.assert_called_once_with(app, user, '/api/v1/accounts/search', {'q': 'blixa'})
assert "Account not found" == str(ex.value)
@mock.patch('toot.http.post')
@mock.patch('toot.http.get')
def test_unfollow(mock_get, mock_post, capsys):
mock_get.return_value = MockResponse([
{'id': 123, 'acct': 'blixa@other.acc'},
{'id': 321, 'acct': 'blixa'},
])
mock_post.return_value = MockResponse()
console.run_command(app, user, 'unfollow', ['blixa'])
mock_get.assert_called_once_with(app, user, '/api/v1/accounts/search', {'q': 'blixa'})
mock_post.assert_called_once_with(app, user, '/api/v1/accounts/321/unfollow')
out, err = capsys.readouterr()
assert "You are no longer following blixa" in out
@mock.patch('toot.http.get')
def test_unfollow_not_found(mock_get, capsys):
mock_get.return_value = MockResponse([])
with pytest.raises(ConsoleError) as ex:
console.run_command(app, user, 'unfollow', ['blixa'])
mock_get.assert_called_once_with(app, user, '/api/v1/accounts/search', {'q': 'blixa'})
assert "Account not found" == str(ex.value)
@mock.patch('toot.http.get')
def test_whoami(mock_get, capsys):
mock_get.return_value = MockResponse({
'acct': 'ihabunek',
'avatar': 'https://files.mastodon.social/accounts/avatars/000/046/103/original/6a1304e135cac514.jpg?1491312434',
'avatar_static': 'https://files.mastodon.social/accounts/avatars/000/046/103/original/6a1304e135cac514.jpg?1491312434',
'created_at': '2017-04-04T13:23:09.777Z',
'display_name': 'Ivan Habunek',
'followers_count': 5,
'following_count': 9,
'header': '/headers/original/missing.png',
'header_static': '/headers/original/missing.png',
'id': 46103,
'locked': False,
'note': 'A developer.',
'statuses_count': 19,
'url': 'https://mastodon.social/@ihabunek',
'username': 'ihabunek'
})
console.run_command(app, user, 'whoami', [])
mock_get.assert_called_once_with(app, user, '/api/v1/accounts/verify_credentials')
out, err = capsys.readouterr()
out = uncolorize(out)
assert "@ihabunek Ivan Habunek" in out
assert "A developer." in out
assert "https://mastodon.social/@ihabunek" in out
assert "ID: 46103" in out
assert "Since: 2017-04-04 @ 13:23:09" in out
assert "Followers: 5" in out
assert "Following: 9" in out
assert "Statuses: 19" in out
@mock.patch('toot.http.get')
def test_notifications(mock_get, capsys):
mock_get.return_value = MockResponse([{
'id': '1',
'type': 'follow',
'created_at': '2019-02-16T07:01:20.714Z',
'account': {
'display_name': 'Frank Zappa',
'acct': 'frank@zappa.social',
},
}, {
'id': '2',
'type': 'mention',
'created_at': '2017-01-12T12:12:12.0Z',
'account': {
'display_name': 'Dweezil Zappa',
'acct': 'dweezil@zappa.social',
},
'status': {
'id': '111111111111111111',
'account': {
'display_name': 'Dweezil Zappa',
'acct': 'dweezil@zappa.social',
},
'created_at': '2017-04-12T15:53:18.174Z',
'content': "We still have fans in 2017 @fan123
",
'reblog': None,
'in_reply_to_id': None,
'media_attachments': [],
},
}, {
'id': '3',
'type': 'reblog',
'created_at': '1983-11-03T03:03:03.333Z',
'account': {
'display_name': 'Terry Bozzio',
'acct': 'terry@bozzio.social',
},
'status': {
'id': '1234',
'account': {
'display_name': 'Zappa Fan',
'acct': 'fan123@zappa-fans.social'
},
'created_at': '1983-11-04T15:53:18.174Z',
'content': "The Black Page, a masterpiece
",
'reblog': None,
'in_reply_to_id': None,
'media_attachments': [],
},
}, {
'id': '4',
'type': 'favourite',
'created_at': '1983-12-13T01:02:03.444Z',
'account': {
'display_name': 'Zappa Old Fan',
'acct': 'fan9@zappa-fans.social',
},
'status': {
'id': '1234',
'account': {
'display_name': 'Zappa Fan',
'acct': 'fan123@zappa-fans.social'
},
'created_at': '1983-11-04T15:53:18.174Z',
'content': "The Black Page, a masterpiece
",
'reblog': None,
'in_reply_to_id': None,
'media_attachments': [],
},
}])
console.run_command(app, user, 'notifications', [])
mock_get.assert_called_once_with(app, user, '/api/v1/notifications', {'exclude_types[]': [], 'limit': 20})
out, err = capsys.readouterr()
out = uncolorize(out)
width = 100
assert not err
assert out == "\n".join([
"─" * width,
"Frank Zappa @frank@zappa.social now follows you",
"─" * width,
"Dweezil Zappa @dweezil@zappa.social mentioned you in",
"Dweezil Zappa @dweezil@zappa.social 2017-04-12 15:53",
"",
"We still have fans in 2017 @fan123",
"",
"ID 111111111111111111 ",
"─" * width,
"Terry Bozzio @terry@bozzio.social reblogged your status",
"Zappa Fan @fan123@zappa-fans.social 1983-11-04 15:53",
"",
"The Black Page, a masterpiece",
"",
"ID 1234 ",
"─" * width,
"Zappa Old Fan @fan9@zappa-fans.social favourited your status",
"Zappa Fan @fan123@zappa-fans.social 1983-11-04 15:53",
"",
"The Black Page, a masterpiece",
"",
"ID 1234 ",
"─" * width,
"",
])
@mock.patch('toot.http.get')
def test_notifications_empty(mock_get, capsys):
mock_get.return_value = MockResponse([])
console.run_command(app, user, 'notifications', [])
mock_get.assert_called_once_with(app, user, '/api/v1/notifications', {'exclude_types[]': [], 'limit': 20})
out, err = capsys.readouterr()
out = uncolorize(out)
assert not err
assert out == "No notification\n"
@mock.patch('toot.http.post')
def test_notifications_clear(mock_post, capsys):
console.run_command(app, user, 'notifications', ['--clear'])
out, err = capsys.readouterr()
out = uncolorize(out)
mock_post.assert_called_once_with(app, user, '/api/v1/notifications/clear')
assert not err
assert out == 'Cleared notifications\n'
def u(user_id, access_token="abc"):
username, instance = user_id.split("@")
return {
"instance": instance,
"username": username,
"access_token": access_token,
}
@mock.patch('toot.config.save_config')
@mock.patch('toot.config.load_config')
def test_logout(mock_load, mock_save, capsys):
mock_load.return_value = {
"users": {
"king@gizzard.social": u("king@gizzard.social"),
"lizard@wizard.social": u("lizard@wizard.social"),
},
"active_user": "king@gizzard.social",
}
console.run_command(app, user, "logout", ["king@gizzard.social"])
mock_save.assert_called_once_with({
'users': {
'lizard@wizard.social': u("lizard@wizard.social")
},
'active_user': None
})
out, err = capsys.readouterr()
assert "✓ User king@gizzard.social logged out" in out
@mock.patch('toot.config.save_config')
@mock.patch('toot.config.load_config')
def test_activate(mock_load, mock_save, capsys):
mock_load.return_value = {
"users": {
"king@gizzard.social": u("king@gizzard.social"),
"lizard@wizard.social": u("lizard@wizard.social"),
},
"active_user": "king@gizzard.social",
}
console.run_command(app, user, "activate", ["lizard@wizard.social"])
mock_save.assert_called_once_with({
'users': {
"king@gizzard.social": u("king@gizzard.social"),
'lizard@wizard.social': u("lizard@wizard.social")
},
'active_user': "lizard@wizard.social"
})
out, err = capsys.readouterr()
assert "✓ User lizard@wizard.social active" in out
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1577956384.0
toot-0.28.0/tests/test_utils.py 0000644 0001750 0001750 00000013406 00000000000 016644 0 ustar 00ihabunek ihabunek from toot.wcstring import wc_wrap, trunc, pad, fit_text
def test_pad():
# guitar symbol will occupy two cells, so padded text should be 1
# character shorter
text = 'Frank Zappa 🎸'
# Negative values are basically ignored
assert pad(text, -100) is text
# Padding to length smaller than text length does nothing
assert pad(text, 11) is text
assert pad(text, 12) is text
assert pad(text, 13) is text
assert pad(text, 14) is text
assert pad(text, 15) == 'Frank Zappa 🎸 '
assert pad(text, 16) == 'Frank Zappa 🎸 '
assert pad(text, 17) == 'Frank Zappa 🎸 '
assert pad(text, 18) == 'Frank Zappa 🎸 '
assert pad(text, 19) == 'Frank Zappa 🎸 '
assert pad(text, 20) == 'Frank Zappa 🎸 '
def test_trunc():
text = 'Frank Zappa 🎸'
assert trunc(text, 1) == '…'
assert trunc(text, 2) == 'F…'
assert trunc(text, 3) == 'Fr…'
assert trunc(text, 4) == 'Fra…'
assert trunc(text, 5) == 'Fran…'
assert trunc(text, 6) == 'Frank…'
assert trunc(text, 7) == 'Frank…'
assert trunc(text, 8) == 'Frank Z…'
assert trunc(text, 9) == 'Frank Za…'
assert trunc(text, 10) == 'Frank Zap…'
assert trunc(text, 11) == 'Frank Zapp…'
assert trunc(text, 12) == 'Frank Zappa…'
assert trunc(text, 13) == 'Frank Zappa…'
# Truncating to length larger than text length does nothing
assert trunc(text, 14) is text
assert trunc(text, 15) is text
assert trunc(text, 16) is text
assert trunc(text, 17) is text
assert trunc(text, 18) is text
assert trunc(text, 19) is text
assert trunc(text, 20) is text
def test_fit_text():
text = 'Frank Zappa 🎸'
assert fit_text(text, 1) == '…'
assert fit_text(text, 2) == 'F…'
assert fit_text(text, 3) == 'Fr…'
assert fit_text(text, 4) == 'Fra…'
assert fit_text(text, 5) == 'Fran…'
assert fit_text(text, 6) == 'Frank…'
assert fit_text(text, 7) == 'Frank…'
assert fit_text(text, 8) == 'Frank Z…'
assert fit_text(text, 9) == 'Frank Za…'
assert fit_text(text, 10) == 'Frank Zap…'
assert fit_text(text, 11) == 'Frank Zapp…'
assert fit_text(text, 12) == 'Frank Zappa…'
assert fit_text(text, 13) == 'Frank Zappa…'
assert fit_text(text, 14) == 'Frank Zappa 🎸'
assert fit_text(text, 15) == 'Frank Zappa 🎸 '
assert fit_text(text, 16) == 'Frank Zappa 🎸 '
assert fit_text(text, 17) == 'Frank Zappa 🎸 '
assert fit_text(text, 18) == 'Frank Zappa 🎸 '
assert fit_text(text, 19) == 'Frank Zappa 🎸 '
assert fit_text(text, 20) == 'Frank Zappa 🎸 '
def test_wc_wrap_plain_text():
lorem = (
"Eius voluptas eos praesentium et tempore. Quaerat nihil voluptatem "
"excepturi reiciendis sapiente voluptate natus. Tenetur occaecati "
"velit dicta dolores. Illo reiciendis nulla ea. Facilis nostrum non "
"qui inventore sit."
)
assert list(wc_wrap(lorem, 50)) == [
#01234567890123456789012345678901234567890123456789 # noqa
"Eius voluptas eos praesentium et tempore. Quaerat",
"nihil voluptatem excepturi reiciendis sapiente",
"voluptate natus. Tenetur occaecati velit dicta",
"dolores. Illo reiciendis nulla ea. Facilis nostrum",
"non qui inventore sit.",
]
def test_wc_wrap_plain_text_wrap_on_any_whitespace():
lorem = (
"Eius\t\tvoluptas\teos\tpraesentium\tet\ttempore.\tQuaerat\tnihil\tvoluptatem\t"
"excepturi\nreiciendis\n\nsapiente\nvoluptate\nnatus.\nTenetur\noccaecati\n"
"velit\rdicta\rdolores.\rIllo\rreiciendis\rnulla\r\r\rea.\rFacilis\rnostrum\rnon\r"
"qui\u2003inventore\u2003\u2003sit." # em space
)
assert list(wc_wrap(lorem, 50)) == [
#01234567890123456789012345678901234567890123456789 # noqa
"Eius voluptas eos praesentium et tempore. Quaerat",
"nihil voluptatem excepturi reiciendis sapiente",
"voluptate natus. Tenetur occaecati velit dicta",
"dolores. Illo reiciendis nulla ea. Facilis nostrum",
"non qui inventore sit.",
]
def test_wc_wrap_text_with_wide_chars():
lorem = (
"☕☕☕☕☕ voluptas eos praesentium et 🎸🎸🎸🎸🎸. Quaerat nihil "
"voluptatem excepturi reiciendis sapiente voluptate natus."
)
assert list(wc_wrap(lorem, 50)) == [
#01234567890123456789012345678901234567890123456789 # noqa
"☕☕☕☕☕ voluptas eos praesentium et 🎸🎸🎸🎸🎸.",
"Quaerat nihil voluptatem excepturi reiciendis",
"sapiente voluptate natus.",
]
def test_wc_wrap_hard_wrap():
lorem = (
"☕☕☕☕☕voluptaseospraesentiumet🎸🎸🎸🎸🎸.Quaeratnihil"
"voluptatemexcepturireiciendissapientevoluptatenatus."
)
assert list(wc_wrap(lorem, 50)) == [
#01234567890123456789012345678901234567890123456789 # noqa
"☕☕☕☕☕voluptaseospraesentiumet🎸🎸🎸🎸🎸.Quaer",
"atnihilvoluptatemexcepturireiciendissapientevolupt",
"atenatus.",
]
def test_wc_wrap_indented():
lorem = (
" Eius voluptas eos praesentium et tempore. Quaerat nihil voluptatem "
" excepturi reiciendis sapiente voluptate natus. Tenetur occaecati "
" velit dicta dolores. Illo reiciendis nulla ea. Facilis nostrum non "
" qui inventore sit."
)
assert list(wc_wrap(lorem, 50)) == [
#01234567890123456789012345678901234567890123456789 # noqa
"Eius voluptas eos praesentium et tempore. Quaerat",
"nihil voluptatem excepturi reiciendis sapiente",
"voluptate natus. Tenetur occaecati velit dicta",
"dolores. Illo reiciendis nulla ea. Facilis nostrum",
"non qui inventore sit.",
]
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1577956384.0
toot-0.28.0/tests/test_version.py 0000644 0001750 0001750 00000000361 00000000000 017165 0 ustar 00ihabunek ihabunek import toot
from pkg_resources import get_distribution
def test_version():
"""Version specified in __version__ should be the same as the one
specified in setup.py."""
assert toot.__version__ == get_distribution('toot').version
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1577956384.0
toot-0.28.0/tests/utils.py 0000644 0001750 0001750 00000000644 00000000000 015605 0 ustar 00ihabunek ihabunek """
Helpers for testing.
"""
class MockResponse:
def __init__(self, response_data={}, ok=True, is_redirect=False):
self.response_data = response_data
self.content = response_data
self.ok = ok
self.is_redirect = is_redirect
def raise_for_status(self):
pass
def json(self):
return self.response_data
def retval(val):
return lambda *args, **kwargs: val
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1630178600.2211607
toot-0.28.0/toot/ 0000755 0001750 0001750 00000000000 00000000000 013712 5 ustar 00ihabunek ihabunek ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1630178213.0
toot-0.28.0/toot/__init__.py 0000644 0001750 0001750 00000000561 00000000000 016025 0 ustar 00ihabunek ihabunek # -*- coding: utf-8 -*-
from collections import namedtuple
__version__ = '0.28.0'
App = namedtuple('App', ['instance', 'base_url', 'client_id', 'client_secret'])
User = namedtuple('User', ['instance', 'username', 'access_token'])
DEFAULT_INSTANCE = 'mastodon.social'
CLIENT_NAME = 'toot - a Mastodon CLI client'
CLIENT_WEBSITE = 'https://github.com/ihabunek/toot'
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1630177344.0
toot-0.28.0/toot/api.py 0000644 0001750 0001750 00000017463 00000000000 015050 0 ustar 00ihabunek ihabunek # -*- coding: utf-8 -*-
import re
import uuid
from urllib.parse import urlparse, urlencode, quote
from toot import http, CLIENT_NAME, CLIENT_WEBSITE
from toot.exceptions import AuthenticationError
from toot.utils import str_bool
SCOPES = 'read write follow'
def _account_action(app, user, account, action):
url = '/api/v1/accounts/{}/{}'.format(account, action)
return http.post(app, user, url).json()
def _status_action(app, user, status_id, action):
url = '/api/v1/statuses/{}/{}'.format(status_id, action)
return http.post(app, user, url).json()
def create_app(domain, scheme='https'):
url = '{}://{}/api/v1/apps'.format(scheme, domain)
data = {
'client_name': CLIENT_NAME,
'redirect_uris': 'urn:ietf:wg:oauth:2.0:oob',
'scopes': SCOPES,
'website': CLIENT_WEBSITE,
}
return http.anon_post(url, data).json()
def login(app, username, password):
url = app.base_url + '/oauth/token'
data = {
'grant_type': 'password',
'client_id': app.client_id,
'client_secret': app.client_secret,
'username': username,
'password': password,
'scope': SCOPES,
}
response = http.anon_post(url, data, allow_redirects=False)
# If auth fails, it redirects to the login page
if response.is_redirect:
raise AuthenticationError()
return response.json()
def get_browser_login_url(app):
"""Returns the URL for manual log in via browser"""
return "{}/oauth/authorize/?{}".format(app.base_url, urlencode({
"response_type": "code",
"redirect_uri": "urn:ietf:wg:oauth:2.0:oob",
"scope": SCOPES,
"client_id": app.client_id,
}))
def request_access_token(app, authorization_code):
url = app.base_url + '/oauth/token'
data = {
'grant_type': 'authorization_code',
'client_id': app.client_id,
'client_secret': app.client_secret,
'code': authorization_code,
'redirect_uri': 'urn:ietf:wg:oauth:2.0:oob',
}
return http.anon_post(url, data, allow_redirects=False).json()
def post_status(
app,
user,
status,
visibility='public',
media_ids=None,
sensitive=False,
spoiler_text=None,
in_reply_to_id=None,
language=None,
scheduled_at=None,
content_type=None,
):
"""
Posts a new status.
https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md#posting-a-new-status
"""
# Idempotency key assures the same status is not posted multiple times
# if the request is retried.
headers = {"Idempotency-Key": uuid.uuid4().hex}
params = {
'status': status,
'media_ids[]': media_ids,
'visibility': visibility,
'sensitive': str_bool(sensitive),
'spoiler_text': spoiler_text,
'in_reply_to_id': in_reply_to_id,
'language': language,
'scheduled_at': scheduled_at
}
if content_type:
params['content_type'] = content_type
return http.post(app, user, '/api/v1/statuses', params, headers=headers).json()
def delete_status(app, user, status_id):
"""
Deletes a status with given ID.
https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md#deleting-a-status
"""
return http.delete(app, user, '/api/v1/statuses/{}'.format(status_id))
def favourite(app, user, status_id):
return _status_action(app, user, status_id, 'favourite')
def unfavourite(app, user, status_id):
return _status_action(app, user, status_id, 'unfavourite')
def reblog(app, user, status_id):
return _status_action(app, user, status_id, 'reblog')
def unreblog(app, user, status_id):
return _status_action(app, user, status_id, 'unreblog')
def pin(app, user, status_id):
return _status_action(app, user, status_id, 'pin')
def unpin(app, user, status_id):
return _status_action(app, user, status_id, 'unpin')
def context(app, user, status_id):
url = '/api/v1/statuses/{}/context'.format(status_id)
return http.get(app, user, url).json()
def reblogged_by(app, user, status_id):
url = '/api/v1/statuses/{}/reblogged_by'.format(status_id)
return http.get(app, user, url).json()
def _get_next_path(headers):
"""Given timeline response headers, returns the path to the next batch"""
links = headers.get('Link', '')
matches = re.match('<([^>]+)>; rel="next"', links)
if matches:
parsed = urlparse(matches.group(1))
return "?".join([parsed.path, parsed.query])
def _timeline_generator(app, user, path, params=None):
while path:
response = http.get(app, user, path, params)
yield response.json()
path = _get_next_path(response.headers)
def home_timeline_generator(app, user, limit=20):
path = '/api/v1/timelines/home?limit={}'.format(limit)
return _timeline_generator(app, user, path)
def public_timeline_generator(app, user, local=False, limit=20):
path = '/api/v1/timelines/public'
params = {'local': str_bool(local), 'limit': limit}
return _timeline_generator(app, user, path, params)
def tag_timeline_generator(app, user, hashtag, local=False, limit=20):
path = '/api/v1/timelines/tag/{}'.format(quote(hashtag))
params = {'local': str_bool(local), 'limit': limit}
return _timeline_generator(app, user, path, params)
def timeline_list_generator(app, user, list_id, limit=20):
path = '/api/v1/timelines/list/{}'.format(list_id)
return _timeline_generator(app, user, path, {'limit': limit})
def _anon_timeline_generator(instance, path, params=None):
while path:
url = "https://{}{}".format(instance, path)
response = http.anon_get(url, params)
yield response.json()
path = _get_next_path(response.headers)
def anon_public_timeline_generator(instance, local=False, limit=20):
path = '/api/v1/timelines/public'
params = {'local': str_bool(local), 'limit': limit}
return _anon_timeline_generator(instance, path, params)
def anon_tag_timeline_generator(instance, hashtag, local=False, limit=20):
path = '/api/v1/timelines/tag/{}'.format(quote(hashtag))
params = {'local': str_bool(local), 'limit': limit}
return _anon_timeline_generator(instance, path, params)
def upload_media(app, user, file, description=None):
return http.post(app, user, '/api/v1/media',
data={'description': description},
files={'file': file}
).json()
def search(app, user, query, resolve):
return http.get(app, user, '/api/v2/search', {
'q': query,
'resolve': resolve,
}).json()
def search_accounts(app, user, query):
return http.get(app, user, '/api/v1/accounts/search', {
'q': query,
}).json()
def follow(app, user, account):
return _account_action(app, user, account, 'follow')
def unfollow(app, user, account):
return _account_action(app, user, account, 'unfollow')
def mute(app, user, account):
return _account_action(app, user, account, 'mute')
def unmute(app, user, account):
return _account_action(app, user, account, 'unmute')
def block(app, user, account):
return _account_action(app, user, account, 'block')
def unblock(app, user, account):
return _account_action(app, user, account, 'unblock')
def verify_credentials(app, user):
return http.get(app, user, '/api/v1/accounts/verify_credentials').json()
def single_status(app, user, status_id):
url = '/api/v1/statuses/{}'.format(status_id)
return http.get(app, user, url).json()
def get_notifications(app, user, exclude_types=[], limit=20):
params={"exclude_types[]": exclude_types, "limit": limit}
return http.get(app, user, '/api/v1/notifications', params).json()
def clear_notifications(app, user):
http.post(app, user, '/api/v1/notifications/clear')
def get_instance(domain, scheme="https"):
url = "{}://{}/api/v1/instance".format(scheme, domain)
return http.anon_get(url).json()
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1630175686.0
toot-0.28.0/toot/auth.py 0000644 0001750 0001750 00000006556 00000000000 015241 0 ustar 00ihabunek ihabunek # -*- coding: utf-8 -*-
import sys
import webbrowser
from builtins import input
from getpass import getpass
from toot import api, config, DEFAULT_INSTANCE, User, App
from toot.exceptions import ApiError, ConsoleError
from toot.output import print_out
def register_app(domain, scheme='https'):
print_out("Looking up instance info...")
instance = api.get_instance(domain, scheme)
print_out("Found instance {} running Mastodon version {}".format(
instance['title'], instance['version']))
try:
print_out("Registering application...")
response = api.create_app(domain, scheme)
except ApiError:
raise ConsoleError("Registration failed.")
base_url = scheme + '://' + domain
app = App(domain, base_url, response['client_id'], response['client_secret'])
config.save_app(app)
print_out("Application tokens saved.")
return app
def create_app_interactive(instance=None, scheme='https'):
if not instance:
print_out("Choose an instance [{}]: ".format(DEFAULT_INSTANCE), end="")
instance = input()
if not instance:
instance = DEFAULT_INSTANCE
return config.load_app(instance) or register_app(instance, scheme)
def create_user(app, access_token):
# Username is not yet known at this point, so fetch it from Mastodon
user = User(app.instance, None, access_token)
creds = api.verify_credentials(app, user)
user = User(app.instance, creds['username'], access_token)
config.save_user(user, activate=True)
print_out("Access token saved to config at: {}".format(
config.get_config_file_path()))
return user
def login_interactive(app, email=None):
print_out("Log in to {}".format(app.instance))
if email:
print_out("Email: {}".format(email))
while not email:
email = input('Email: ')
# Accept password piped from stdin, useful for testing purposes but not
# documented so people won't get ideas. Otherwise prompt for password.
if sys.stdin.isatty():
password = getpass('Password: ')
else:
password = sys.stdin.read().strip()
print_out("Password: read from stdin")
try:
print_out("Authenticating...")
response = api.login(app, email, password)
except ApiError:
raise ConsoleError("Login failed")
return create_user(app, response['access_token'])
BROWSER_LOGIN_EXPLANATION = """
This authentication method requires you to log into your Mastodon instance
in your browser, where you will be asked to authorize toot to access
your account. When you do, you will be given an authorization code
which you need to paste here.
"""
def login_browser_interactive(app):
url = api.get_browser_login_url(app)
print_out(BROWSER_LOGIN_EXPLANATION)
print_out("This is the login URL:")
print_out(url)
print_out("")
yesno = input("Open link in default browser? [Y/n]")
if not yesno or yesno.lower() == 'y':
webbrowser.open(url)
authorization_code = ""
while not authorization_code:
authorization_code = input("Authorization code: ")
print_out("\nRequesting access token...")
response = api.request_access_token(app, authorization_code)
return create_user(app, response['access_token'])
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1630177275.0
toot-0.28.0/toot/commands.py 0000644 0001750 0001750 00000025402 00000000000 016070 0 ustar 00ihabunek ihabunek # -*- coding: utf-8 -*-
import sys
from toot import api, config
from toot.auth import login_interactive, login_browser_interactive, create_app_interactive
from toot.exceptions import ConsoleError, NotFoundError
from toot.output import (print_out, print_instance, print_account,
print_search_results, print_timeline, print_notifications)
from toot.utils import assert_domain_exists, editor_input, multiline_input, EOF_KEY
def get_timeline_generator(app, user, args):
# Make sure tag, list and public are not used simultaneously
if len([arg for arg in [args.tag, args.list, args.public] if arg]) > 1:
raise ConsoleError("Only one of --public, --tag, or --list can be used at one time.")
if args.local and not (args.public or args.tag):
raise ConsoleError("The --local option is only valid alongside --public or --tag.")
if args.instance and not (args.public or args.tag):
raise ConsoleError("The --instance option is only valid alongside --public or --tag.")
if args.public:
if args.instance:
return api.anon_public_timeline_generator(args.instance, local=args.local, limit=args.count)
else:
return api.public_timeline_generator(app, user, local=args.local, limit=args.count)
elif args.tag:
if args.instance:
return api.anon_tag_timeline_generator(args.instance, args.tag, limit=args.count)
else:
return api.tag_timeline_generator(app, user, args.tag, local=args.local, limit=args.count)
elif args.list:
return api.timeline_list_generator(app, user, args.list, limit=args.count)
else:
return api.home_timeline_generator(app, user, limit=args.count)
def timeline(app, user, args):
generator = get_timeline_generator(app, user, args)
while(True):
try:
items = next(generator)
except StopIteration:
print_out("That's all folks.")
return
if args.reverse:
items = reversed(items)
print_timeline(items)
if args.once or not sys.stdout.isatty():
break
char = input("\nContinue? [Y/n] ")
if char.lower() == "n":
break
def thread(app, user, args):
toot = api.single_status(app, user, args.status_id)
context = api.context(app, user, args.status_id)
thread = []
for item in context['ancestors']:
thread.append(item)
thread.append(toot)
for item in context['descendants']:
thread.append(item)
print_timeline(thread)
def post(app, user, args):
# TODO: this might be achievable, explore options
if args.editor and not sys.stdin.isatty():
raise ConsoleError("Cannot run editor if not in tty.")
if args.media and len(args.media) > 4:
raise ConsoleError("Cannot attach more than 4 files.")
# Read any text that might be piped to stdin
if not args.text and not sys.stdin.isatty():
args.text = sys.stdin.read().rstrip()
# Match media to corresponding description and upload
media = args.media or []
descriptions = args.description or []
uploaded_media = []
for idx, file in enumerate(media):
description = descriptions[idx].strip() if idx < len(descriptions) else None
result = _do_upload(app, user, file, description)
uploaded_media.append(result)
media_ids = [m["id"] for m in uploaded_media]
if uploaded_media and not args.text:
args.text = "\n".join(m['text_url'] for m in uploaded_media)
if args.editor:
args.text = editor_input(args.editor, args.text)
elif not args.text:
print_out("Write or paste your toot. Press {} to post it.".format(EOF_KEY))
args.text = multiline_input()
if not args.text:
raise ConsoleError("You must specify either text or media to post.")
response = api.post_status(
app, user, args.text,
visibility=args.visibility,
media_ids=media_ids,
sensitive=args.sensitive,
spoiler_text=args.spoiler_text,
in_reply_to_id=args.reply_to,
language=args.language,
scheduled_at=args.scheduled_at,
content_type=args.content_type
)
if "scheduled_at" in response:
print_out("Toot scheduled for: {}".format(response["scheduled_at"]))
else:
print_out("Toot posted: {}".format(response.get('url')))
def delete(app, user, args):
api.delete_status(app, user, args.status_id)
print_out("✓ Status deleted")
def favourite(app, user, args):
api.favourite(app, user, args.status_id)
print_out("✓ Status favourited")
def unfavourite(app, user, args):
api.unfavourite(app, user, args.status_id)
print_out("✓ Status unfavourited")
def reblog(app, user, args):
api.reblog(app, user, args.status_id)
print_out("✓ Status reblogged")
def unreblog(app, user, args):
api.unreblog(app, user, args.status_id)
print_out("✓ Status unreblogged")
def pin(app, user, args):
api.pin(app, user, args.status_id)
print_out("✓ Status pinned")
def unpin(app, user, args):
api.unpin(app, user, args.status_id)
print_out("✓ Status unpinned")
def reblogged_by(app, user, args):
for account in api.reblogged_by(app, user, args.status_id):
print_out("{}\n @{}".format(account['display_name'], account['acct']))
def auth(app, user, args):
config_data = config.load_config()
if not config_data["users"]:
print_out("You are not logged in to any accounts")
return
active_user = config_data["active_user"]
print_out("Authenticated accounts:")
for uid, u in config_data["users"].items():
active_label = "ACTIVE" if active_user == uid else ""
print_out("* {} {}".format(uid, active_label))
path = config.get_config_file_path()
print_out("\nAuth tokens are stored in: {}".format(path))
def login_cli(app, user, args):
app = create_app_interactive(instance=args.instance, scheme=args.scheme)
login_interactive(app, args.email)
print_out()
print_out("✓ Successfully logged in.")
def login(app, user, args):
app = create_app_interactive(instance=args.instance, scheme=args.scheme)
login_browser_interactive(app)
print_out()
print_out("✓ Successfully logged in.")
def logout(app, user, args):
user = config.load_user(args.account, throw=True)
config.delete_user(user)
print_out("✓ User {} logged out".format(config.user_id(user)))
def activate(app, user, args):
user = config.load_user(args.account, throw=True)
config.activate_user(user)
print_out("✓ User {} active".format(config.user_id(user)))
def upload(app, user, args):
response = _do_upload(app, user, args.file, args.description)
msg = "Successfully uploaded media ID {}, type '{}'"
print_out()
print_out(msg.format(response['id'], response['type']))
print_out("Original URL: {}".format(response['url']))
print_out("Preview URL: {}".format(response['preview_url']))
print_out("Text URL: {}".format(response['text_url']))
def search(app, user, args):
response = api.search(app, user, args.query, args.resolve)
print_search_results(response)
def _do_upload(app, user, file, description):
print_out("Uploading media: {}".format(file.name))
return api.upload_media(app, user, file, description=description)
def _find_account(app, user, account_name):
"""For a given account name, returns the Account object.
Raises an exception if not found.
"""
if not account_name:
raise ConsoleError("Empty account name given")
accounts = api.search_accounts(app, user, account_name)
if account_name[0] == "@":
account_name = account_name[1:]
for account in accounts:
if account['acct'] == account_name:
return account
raise ConsoleError("Account not found")
def follow(app, user, args):
account = _find_account(app, user, args.account)
api.follow(app, user, account['id'])
print_out("✓ You are now following {}".format(args.account))
def unfollow(app, user, args):
account = _find_account(app, user, args.account)
api.unfollow(app, user, account['id'])
print_out("✓ You are no longer following {}".format(args.account))
def mute(app, user, args):
account = _find_account(app, user, args.account)
api.mute(app, user, account['id'])
print_out("✓ You have muted {}".format(args.account))
def unmute(app, user, args):
account = _find_account(app, user, args.account)
api.unmute(app, user, account['id'])
print_out("✓ {} is no longer muted".format(args.account))
def block(app, user, args):
account = _find_account(app, user, args.account)
api.block(app, user, account['id'])
print_out("✓ You are now blocking {}".format(args.account))
def unblock(app, user, args):
account = _find_account(app, user, args.account)
api.unblock(app, user, account['id'])
print_out("✓ {} is no longer blocked".format(args.account))
def whoami(app, user, args):
account = api.verify_credentials(app, user)
print_account(account)
def whois(app, user, args):
account = _find_account(app, user, args.account)
print_account(account)
def instance(app, user, args):
name = args.instance or (app and app.instance)
if not name:
raise ConsoleError("Please specify instance name.")
assert_domain_exists(name)
try:
instance = api.get_instance(name, args.scheme)
print_instance(instance)
except NotFoundError:
raise ConsoleError(
"Instance not found at {}.\n"
"The given domain probably does not host a Mastodon instance.".format(name)
)
def notifications(app, user, args):
if args.clear:
api.clear_notifications(app, user)
print_out("Cleared notifications")
return
exclude = []
if args.mentions:
# Filter everything except mentions
# https://docs.joinmastodon.org/methods/notifications/
exclude = ["follow", "favourite", "reblog", "poll", "follow_request"]
notifications = api.get_notifications(app, user, exclude_types=exclude)
if not notifications:
print_out("No notification")
return
if args.reverse:
notifications = reversed(notifications)
print_notifications(notifications)
def tui(app, user, args):
from .tui.app import TUI
TUI.create(app, user).run()
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1630168829.0
toot-0.28.0/toot/config.py 0000644 0001750 0001750 00000010171 00000000000 015531 0 ustar 00ihabunek ihabunek import json
import os
import sys
from functools import wraps
from os.path import dirname, join, expanduser
from toot import User, App
from toot.exceptions import ConsoleError
from toot.output import print_out
TOOT_CONFIG_DIR_NAME = "toot"
TOOT_CONFIG_FILE_NAME = "config.json"
def get_config_dir():
"""Returns the path to toot config directory"""
# On Windows, store the config in roaming appdata
if sys.platform == "win32" and "APPDATA" in os.environ:
return join(os.getenv("APPDATA"), TOOT_CONFIG_DIR_NAME)
# Respect XDG_CONFIG_HOME env variable if set
# https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
if "XDG_CONFIG_HOME" in os.environ:
config_home = expanduser(os.environ["XDG_CONFIG_HOME"])
return join(config_home, TOOT_CONFIG_DIR_NAME)
# Default to ~/.config/toot/
return join(expanduser("~"), ".config", TOOT_CONFIG_DIR_NAME)
def get_config_file_path():
"""Returns the path to toot config file."""
return join(get_config_dir(), TOOT_CONFIG_FILE_NAME)
CONFIG_FILE = get_config_file_path()
def user_id(user):
return "{}@{}".format(user.username, user.instance)
def make_config(path):
"""Creates an empty toot configuration file."""
config = {
"apps": {},
"users": {},
"active_user": None,
}
print_out("Creating config file at {}".format(path))
# Ensure dir exists
os.makedirs(dirname(path), exist_ok=True)
# Create file with 600 permissions since it contains secrets
fd = os.open(path, os.O_CREAT | os.O_WRONLY, 0o600)
with os.fdopen(fd, 'w') as f:
json.dump(config, f, indent=True)
def load_config():
if not os.path.exists(CONFIG_FILE):
make_config(CONFIG_FILE)
with open(CONFIG_FILE) as f:
return json.load(f)
def save_config(config):
with open(CONFIG_FILE, 'w') as f:
return json.dump(config, f, indent=True, sort_keys=True)
def extract_user_app(config, user_id):
if user_id not in config['users']:
return None, None
user_data = config['users'][user_id]
instance = user_data['instance']
if instance not in config['apps']:
return None, None
app_data = config['apps'][instance]
return User(**user_data), App(**app_data)
def get_active_user_app():
"""Returns (User, App) of active user or (None, None) if no user is active."""
config = load_config()
if config['active_user']:
return extract_user_app(config, config['active_user'])
return None, None
def get_user_app(user_id):
"""Returns (User, App) for given user ID or (None, None) if user is not logged in."""
return extract_user_app(load_config(), user_id)
def load_app(instance):
config = load_config()
if instance in config['apps']:
return App(**config['apps'][instance])
def load_user(user_id, throw=False):
config = load_config()
if user_id in config['users']:
return User(**config['users'][user_id])
if throw:
raise ConsoleError("User '{}' not found".format(user_id))
def modify_config(f):
@wraps(f)
def wrapper(*args, **kwargs):
config = load_config()
config = f(config, *args, **kwargs)
save_config(config)
return config
return wrapper
@modify_config
def save_app(config, app):
assert isinstance(app, App)
config['apps'][app.instance] = app._asdict()
return config
@modify_config
def delete_app(config, app):
assert isinstance(app, App)
config['apps'].pop(app.instance, None)
return config
@modify_config
def save_user(config, user, activate=True):
assert isinstance(user, User)
config['users'][user_id(user)] = user._asdict()
if activate:
config['active_user'] = user_id(user)
return config
@modify_config
def delete_user(config, user):
assert isinstance(user, User)
config['users'].pop(user_id(user), None)
if config['active_user'] == user_id(user):
config['active_user'] = None
return config
@modify_config
def activate_user(config, user):
assert isinstance(user, User)
config['active_user'] = user_id(user)
return config
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1630177604.0
toot-0.28.0/toot/console.py 0000644 0001750 0001750 00000036500 00000000000 015732 0 ustar 00ihabunek ihabunek # -*- coding: utf-8 -*-
import logging
import os
import shutil
import sys
from argparse import ArgumentParser, FileType, ArgumentTypeError
from collections import namedtuple
from toot import config, commands, CLIENT_NAME, CLIENT_WEBSITE, __version__
from toot.exceptions import ApiError, ConsoleError
from toot.output import print_out, print_err
VISIBILITY_CHOICES = ['public', 'unlisted', 'private', 'direct']
def language(value):
"""Validates the language parameter"""
if len(value) != 3:
raise ArgumentTypeError(
"Invalid language specified: '{}'. Expected a 3 letter "
"abbreviation according to ISO 639-2 standard.".format(value)
)
return value
def visibility(value):
"""Validates the visibility parameter"""
if value not in VISIBILITY_CHOICES:
raise ValueError("Invalid visibility value")
return value
def timeline_count(value):
n = int(value)
if not 0 < n <= 20:
raise ArgumentTypeError("Number of toots should be between 1 and 20.")
return n
def editor(value):
if not value:
raise ArgumentTypeError(
"Editor not specified in --editor option and $EDITOR environment "
"variable not set."
)
# Check editor executable exists
exe = shutil.which(value)
if not exe:
raise ArgumentTypeError("Editor `{}` not found".format(value))
return exe
Command = namedtuple("Command", ["name", "description", "require_auth", "arguments"])
# Arguments added to every command
common_args = [
(["--no-color"], {
"help": "don't use ANSI colors in output",
"action": 'store_true',
"default": False,
}),
(["--quiet"], {
"help": "don't write to stdout on success",
"action": 'store_true',
"default": False,
}),
(["--debug"], {
"help": "show debug log in console",
"action": 'store_true',
"default": False,
}),
]
# Arguments added to commands which require authentication
common_auth_args = [
(["-u", "--using"], {
"help": "the account to use, overrides active account",
}),
]
account_arg = (["account"], {
"help": "account name, e.g. 'Gargron@mastodon.social'",
})
instance_arg = (["-i", "--instance"], {
"type": str,
"help": 'mastodon instance to log into e.g. "mastodon.social"',
})
email_arg = (["-e", "--email"], {
"type": str,
"help": 'email address to log in with',
})
scheme_arg = (["--disable-https"], {
"help": "disable HTTPS and use insecure HTTP",
"dest": "scheme",
"default": "https",
"action": "store_const",
"const": "http",
})
status_id_arg = (["status_id"], {
"help": "ID of the status",
"type": str,
})
# Arguments for selecting a timeline (see `toot.commands.get_timeline_generator`)
common_timeline_args = [
(["-p", "--public"], {
"action": "store_true",
"default": False,
"help": "show public timeline (does not require auth)",
}),
(["-t", "--tag"], {
"type": str,
"help": "show hashtag timeline (does not require auth)",
}),
(["-l", "--local"], {
"action": "store_true",
"default": False,
"help": "show only statuses from local instance (public and tag timelines only)",
}),
(["-i", "--instance"], {
"type": str,
"help": "mastodon instance from which to read (public and tag timelines only)",
}),
(["--list"], {
"type": str,
"help": "show timeline for given list.",
}),
]
timeline_args = common_timeline_args + [
(["-c", "--count"], {
"type": timeline_count,
"help": "number of toots to show per page (1-20, default 10).",
"default": 10,
}),
(["-r", "--reverse"], {
"action": "store_true",
"default": False,
"help": "Reverse the order of the shown timeline (to new posts at the bottom)",
}),
(["-1", "--once"], {
"action": "store_true",
"default": False,
"help": "Only show the first toots, do not prompt to continue.",
}),
]
AUTH_COMMANDS = [
Command(
name="login",
description="Log into a mastodon instance using your browser (recommended)",
arguments=[instance_arg, scheme_arg],
require_auth=False,
),
Command(
name="login_cli",
description="Log in from the console, does NOT support two factor authentication",
arguments=[instance_arg, email_arg, scheme_arg],
require_auth=False,
),
Command(
name="activate",
description="Switch between logged in accounts.",
arguments=[account_arg],
require_auth=False,
),
Command(
name="logout",
description="Log out, delete stored access keys",
arguments=[account_arg],
require_auth=False,
),
Command(
name="auth",
description="Show logged in accounts and instances",
arguments=[],
require_auth=False,
),
]
TUI_COMMANDS = [
Command(
name="tui",
description="Launches the toot terminal user interface",
arguments=[],
require_auth=True,
),
]
READ_COMMANDS = [
Command(
name="whoami",
description="Display logged in user details",
arguments=[],
require_auth=True,
),
Command(
name="whois",
description="Display account details",
arguments=[
(["account"], {
"help": "account name or numeric ID"
}),
],
require_auth=True,
),
Command(
name="notifications",
description="Notifications for logged in user",
arguments=[
(["--clear"], {
"help": "delete all notifications from the server",
"action": 'store_true',
"default": False,
}),
(["-r", "--reverse"], {
"action": "store_true",
"default": False,
"help": "Reverse the order of the shown notifications (newest on top)",
}),
(["-m", "--mentions"], {
"action": "store_true",
"default": False,
"help": "Only print mentions",
})
],
require_auth=True,
),
Command(
name="instance",
description="Display instance details",
arguments=[
(["instance"], {
"help": "instance domain (e.g. 'mastodon.social') or blank to use current",
"nargs": "?",
}),
scheme_arg,
],
require_auth=False,
),
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="thread",
description="Show toot thread items",
arguments=[
(["status_id"], {
"help": "Show thread for toot.",
}),
],
require_auth=True,
),
Command(
name="timeline",
description="Show recent items in a timeline (home by default)",
arguments=timeline_args,
require_auth=True,
),
]
POST_COMMANDS = [
Command(
name="post",
description="Post a status text to your timeline",
arguments=[
(["text"], {
"help": "The status text to post.",
"nargs": "?",
}),
(["-m", "--media"], {
"action": "append",
"type": FileType("rb"),
"help": "path to the media file to attach (specify multiple "
"times to attach up to 4 files)"
}),
(["-d", "--description"], {
"action": "append",
"type": str,
"help": "plain-text description of the media for accessibility "
"purposes, one per attached media"
}),
(["-v", "--visibility"], {
"type": visibility,
"default": "public",
"help": 'post visibility, one of: %s' % ", ".join(VISIBILITY_CHOICES),
}),
(["-s", "--sensitive"], {
"action": 'store_true',
"default": False,
"help": "mark the media as NSFW",
}),
(["-p", "--spoiler-text"], {
"type": str,
"help": "text to be shown as a warning before the actual content",
}),
(["-r", "--reply-to"], {
"type": str,
"help": "local ID of the status you want to reply to",
}),
(["-l", "--language"], {
"type": language,
"help": "ISO 639-2 language code of the toot, to skip automatic detection",
}),
(["-e", "--editor"], {
"type": editor,
"nargs": "?",
"const": os.getenv("EDITOR", ""), # option given without value
"help": "Specify an editor to compose your toot, "
"defaults to editor defined in $EDITOR env variable.",
}),
(["--scheduled-at"], {
"type": str,
"help": "ISO 8601 Datetime at which to schedule a status. Must "
"be at least 5 minutes in the future.",
}),
(["-t", "--content-type"], {
"type": str,
"help": "MIME type for the status text (not supported on all instances)",
}),
],
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')
}),
(["-d", "--description"], {
"type": str,
"help": "plain-text description of the media for accessibility purposes"
}),
],
require_auth=True,
),
]
STATUS_COMMANDS = [
Command(
name="delete",
description="Delete a status",
arguments=[status_id_arg],
require_auth=True,
),
Command(
name="favourite",
description="Favourite a status",
arguments=[status_id_arg],
require_auth=True,
),
Command(
name="unfavourite",
description="Unfavourite a status",
arguments=[status_id_arg],
require_auth=True,
),
Command(
name="reblog",
description="Reblog a status",
arguments=[status_id_arg],
require_auth=True,
),
Command(
name="unreblog",
description="Unreblog a status",
arguments=[status_id_arg],
require_auth=True,
),
Command(
name="reblogged_by",
description="Show accounts that reblogged the status",
arguments=[status_id_arg],
require_auth=False,
),
Command(
name="pin",
description="Pin a status",
arguments=[status_id_arg],
require_auth=True,
),
Command(
name="unpin",
description="Unpin a status",
arguments=[status_id_arg],
require_auth=True,
),
]
ACCOUNTS_COMMANDS = [
Command(
name="follow",
description="Follow an account",
arguments=[
account_arg,
],
require_auth=True,
),
Command(
name="unfollow",
description="Unfollow an account",
arguments=[
account_arg,
],
require_auth=True,
),
Command(
name="mute",
description="Mute an account",
arguments=[
account_arg,
],
require_auth=True,
),
Command(
name="unmute",
description="Unmute an account",
arguments=[
account_arg,
],
require_auth=True,
),
Command(
name="block",
description="Block an account",
arguments=[
account_arg,
],
require_auth=True,
),
Command(
name="unblock",
description="Unblock an account",
arguments=[
account_arg,
],
require_auth=True,
),
]
COMMANDS = AUTH_COMMANDS + READ_COMMANDS + TUI_COMMANDS + POST_COMMANDS + STATUS_COMMANDS + ACCOUNTS_COMMANDS
def print_usage():
max_name_len = max(len(command.name) for command in COMMANDS)
groups = [
("Authentication", AUTH_COMMANDS),
("TUI", TUI_COMMANDS),
("Read", READ_COMMANDS),
("Post", POST_COMMANDS),
("Status", STATUS_COMMANDS),
("Accounts", ACCOUNTS_COMMANDS),
]
print_out("{}".format(CLIENT_NAME))
print_out("v{}".format(__version__))
for name, cmds in groups:
print_out("")
print_out(name + ":")
for cmd in cmds:
cmd_name = cmd.name.ljust(max_name_len + 2)
print_out(" toot {} {}".format(cmd_name, cmd.description))
print_out("")
print_out("To get help for each command run:")
print_out(" toot --help")
print_out("")
print_out("{}".format(CLIENT_WEBSITE))
def get_argument_parser(name, command):
parser = ArgumentParser(
prog='toot %s' % name,
description=command.description,
epilog=CLIENT_WEBSITE)
combined_args = command.arguments + common_args
if command.require_auth:
combined_args += common_auth_args
for args, kwargs in combined_args:
parser.add_argument(*args, **kwargs)
return parser
def run_command(app, user, name, args):
command = next((c for c in COMMANDS if c.name == name), None)
if not command:
print_err("Unknown command '{}'\n".format(name))
print_usage()
return
parser = get_argument_parser(name, command)
parsed_args = parser.parse_args(args)
# Override the active account if 'using' option is given
if command.require_auth and parsed_args.using:
user, app = config.get_user_app(parsed_args.using)
if not user or not app:
raise ConsoleError("User '{}' not found".format(parsed_args.using))
if command.require_auth and (not user or not app):
print_err("This command requires that you are logged in.")
print_err("Please run `toot login` first.")
return
fn = commands.__dict__.get(name)
if not fn:
raise NotImplementedError("Command '{}' does not have an implementation.".format(name))
return fn(app, user, parsed_args)
def main():
# Enable debug logging if --debug is in args
if "--debug" in sys.argv:
filename = os.getenv("TOOT_LOG_FILE")
logging.basicConfig(level=logging.DEBUG, filename=filename)
command_name = sys.argv[1] if len(sys.argv) > 1 else None
args = sys.argv[2:]
if not command_name:
return print_usage()
user, app = config.get_active_user_app()
try:
run_command(app, user, command_name, args)
except (ConsoleError, ApiError) as e:
print_err(str(e))
sys.exit(1)
except KeyboardInterrupt:
pass
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1630168829.0
toot-0.28.0/toot/exceptions.py 0000644 0001750 0001750 00000000546 00000000000 016452 0 ustar 00ihabunek ihabunek class ApiError(Exception):
"""Raised when an API request fails for whatever reason."""
class NotFoundError(ApiError):
"""Raised when an API requests returns a 404."""
class AuthenticationError(ApiError):
"""Raised when login fails."""
class ConsoleError(Exception):
"""Raised when an error occurs which needs to be show to the user."""
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1630168829.0
toot-0.28.0/toot/http.py 0000644 0001750 0001750 00000004774 00000000000 015257 0 ustar 00ihabunek ihabunek from requests import Request, Session
from toot import __version__
from toot.exceptions import NotFoundError, ApiError
from toot.logging import log_request, log_response
def send_request(request, allow_redirects=True):
# Set a user agent string
# Required for accesing instances using Cloudfront DDOS protection.
request.headers["User-Agent"] = "toot/{}".format(__version__)
log_request(request)
with Session() as session:
prepared = session.prepare_request(request)
settings = session.merge_environment_settings(prepared.url, {}, None, None, None)
response = session.send(prepared, allow_redirects=allow_redirects, **settings)
log_response(response)
return response
def _get_error_message(response):
"""Attempt to extract an error message from response body"""
try:
data = response.json()
if "error_description" in data:
return data['error_description']
if "error" in data:
return data['error']
except Exception:
pass
return "Unknown error"
def process_response(response):
if not response.ok:
error = _get_error_message(response)
if response.status_code == 404:
raise NotFoundError(error)
raise ApiError(error)
return response
def get(app, user, url, params=None):
url = app.base_url + url
headers = {"Authorization": "Bearer " + user.access_token}
request = Request('GET', url, headers, params=params)
response = send_request(request)
return process_response(response)
def anon_get(url, params=None):
request = Request('GET', url, None, params=params)
response = send_request(request)
return process_response(response)
def post(app, user, url, data=None, files=None, allow_redirects=True, headers={}):
url = app.base_url + url
headers["Authorization"] = "Bearer " + user.access_token
request = Request('POST', url, headers, files, data)
response = send_request(request, allow_redirects)
return process_response(response)
def delete(app, user, url, data=None):
url = app.base_url + url
headers = {"Authorization": "Bearer " + user.access_token}
request = Request('DELETE', url, headers=headers, data=data)
response = send_request(request)
return process_response(response)
def anon_post(url, data=None, files=None, allow_redirects=True):
request = Request('POST', url, {}, files, data)
response = send_request(request, allow_redirects)
return process_response(response)
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1630168829.0
toot-0.28.0/toot/logging.py 0000644 0001750 0001750 00000002302 00000000000 015707 0 ustar 00ihabunek ihabunek from logging import getLogger
logger = getLogger('toot')
def censor_secrets(headers):
def _censor(k, v):
if k == "Authorization":
return (k, "***CENSORED***")
return k, v
return {_censor(k, v) for k, v in headers.items()}
def log_request(request):
logger.debug(">>> \033[32m{} {}\033[0m".format(request.method, request.url))
if request.headers:
headers = censor_secrets(request.headers)
logger.debug(">>> HEADERS: \033[33m{}\033[0m".format(headers))
if request.data:
logger.debug(">>> DATA: \033[33m{}\033[0m".format(request.data))
if request.files:
logger.debug(">>> FILES: \033[33m{}\033[0m".format(request.files))
if request.params:
logger.debug(">>> PARAMS: \033[33m{}\033[0m".format(request.params))
def log_response(response):
if response.ok:
logger.debug("<<< \033[32m{}\033[0m".format(response))
logger.debug("<<< \033[33m{}\033[0m".format(response.content))
else:
logger.debug("<<< \033[31m{}\033[0m".format(response))
logger.debug("<<< \033[31m{}\033[0m".format(response.content))
def log_debug(*msgs):
logger.debug(" ".join(str(m) for m in msgs))
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1630168829.0
toot-0.28.0/toot/output.py 0000644 0001750 0001750 00000014263 00000000000 015632 0 ustar 00ihabunek ihabunek # -*- coding: utf-8 -*-
import os
import re
import sys
from datetime import datetime
from textwrap import wrap
from wcwidth import wcswidth
from toot.utils import format_content, get_text, parse_html
from toot.wcstring import wc_wrap
START_CODES = {
'red': '\033[31m',
'green': '\033[32m',
'yellow': '\033[33m',
'blue': '\033[34m',
'magenta': '\033[35m',
'cyan': '\033[36m',
}
END_CODE = '\033[0m'
START_PATTERN = "<(" + "|".join(START_CODES.keys()) + ")>"
END_PATTERN = "(" + "|".join(START_CODES.keys()) + ")>"
def start_code(match):
name = match.group(1)
return START_CODES[name]
def colorize(text):
text = re.sub(START_PATTERN, start_code, text)
text = re.sub(END_PATTERN, END_CODE, text)
return text
def strip_tags(text):
text = re.sub(START_PATTERN, '', text)
text = re.sub(END_PATTERN, '', text)
return text
def use_ansi_color():
"""Returns True if ANSI color codes should be used."""
# Windows doesn't support color unless ansicon is installed
# See: http://adoxa.altervista.org/ansicon/
if sys.platform == 'win32' and 'ANSICON' not in os.environ:
return False
# Don't show color if stdout is not a tty, e.g. if output is piped on
if not sys.stdout.isatty():
return False
# Don't show color if explicitly specified in options
if "--no-color" in sys.argv:
return False
return True
USE_ANSI_COLOR = use_ansi_color()
QUIET = "--quiet" in sys.argv
def print_out(*args, **kwargs):
if not QUIET:
args = [colorize(a) if USE_ANSI_COLOR else strip_tags(a) for a in args]
print(*args, **kwargs)
def print_err(*args, **kwargs):
args = ["{}".format(a) for a in args]
args = [colorize(a) if USE_ANSI_COLOR else strip_tags(a) for a in args]
print(*args, file=sys.stderr, **kwargs)
def print_instance(instance):
print_out("{}".format(instance['title']))
print_out("{}".format(instance['uri']))
print_out("running Mastodon {}".format(instance['version']))
print_out("")
description = instance['description'].strip()
if not description:
return
lines = [line.strip() for line in format_content(description) if line.strip()]
for line in lines:
for l in wrap(line.strip()):
print_out(l)
print_out()
def print_account(account):
print_out("@{} {}".format(account['acct'], account['display_name']))
note = get_text(account['note'])
if note:
print_out("")
print_out("\n".join(wrap(note)))
print_out("")
print_out("ID: {}".format(account['id']))
print_out("Since: {}".format(account['created_at'][:19].replace('T', ' @ ')))
print_out("")
print_out("Followers: {}".format(account['followers_count']))
print_out("Following: {}".format(account['following_count']))
print_out("Statuses: {}".format(account['statuses_count']))
print_out("")
print_out(account['url'])
HASHTAG_PATTERN = re.compile(r'(?\\1', line)
def print_search_results(results):
accounts = results['accounts']
hashtags = results['hashtags']
if accounts:
print_out("\nAccounts:")
for account in accounts:
print_out("* @{} {}".format(
account['acct'],
account['display_name']
))
if hashtags:
print_out("\nHashtags:")
print_out(", ".join(["#{}".format(t["name"]) for t in hashtags]))
if not accounts and not hashtags:
print_out("Nothing found")
def print_status(status, width):
reblog = status['reblog']
content = reblog['content'] if reblog else status['content']
media_attachments = reblog['media_attachments'] if reblog else status['media_attachments']
in_reply_to = status['in_reply_to_id']
time = status['created_at']
time = datetime.strptime(time, "%Y-%m-%dT%H:%M:%S.%fZ")
time = time.strftime('%Y-%m-%d %H:%M%z')
username = "@" + status['account']['acct']
spacing = width - wcswidth(username) - wcswidth(time)
display_name = status['account']['display_name']
if display_name:
spacing -= wcswidth(display_name) + 1
print_out("{}{}{}{}".format(
"{} ".format(display_name) if display_name else "",
"{}".format(username),
" " * spacing,
"{}".format(time),
))
for paragraph in parse_html(content):
print_out("")
for line in paragraph:
for subline in wc_wrap(line, width):
print_out(highlight_hashtags(subline))
if media_attachments:
print_out("\nMedia:")
for attachment in media_attachments:
url = attachment['text_url'] or attachment['url']
for line in wc_wrap(url, width):
print_out(line)
print_out("\n{}{}{}".format(
"ID {} ".format(status['id']),
"↲ In reply to {} ".format(in_reply_to) if in_reply_to else "",
"↻ Reblogged @{} ".format(reblog['account']['acct']) if reblog else "",
))
def print_timeline(items, width=100):
print_out("─" * width)
for item in items:
print_status(item, width)
print_out("─" * width)
notification_msgs = {
"follow": "{account} now follows you",
"mention": "{account} mentioned you in",
"reblog": "{account} reblogged your status",
"favourite": "{account} favourited your status",
}
def print_notification(notification, width=100):
account = "{display_name} @{acct}".format(**notification["account"])
msg = notification_msgs.get(notification["type"])
if msg is None:
return
print_out("─" * width)
print_out(msg.format(account=account))
status = notification.get("status")
if status is not None:
print_status(status, width)
def print_notifications(notifications, width=100):
for notification in notifications:
print_notification(notification)
print_out("─" * width)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1630178600.2211607
toot-0.28.0/toot/tui/ 0000755 0001750 0001750 00000000000 00000000000 014513 5 ustar 00ihabunek ihabunek ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1577956384.0
toot-0.28.0/toot/tui/__init__.py 0000644 0001750 0001750 00000000447 00000000000 016631 0 ustar 00ihabunek ihabunek from urwid.command_map import command_map
from urwid.command_map import CURSOR_UP, CURSOR_DOWN, CURSOR_LEFT, CURSOR_RIGHT
# Add movement using h/j/k/l to default command map
command_map._command.update({
'k': CURSOR_UP,
'j': CURSOR_DOWN,
'h': CURSOR_LEFT,
'l': CURSOR_RIGHT,
})
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1601366697.0
toot-0.28.0/toot/tui/app.py 0000644 0001750 0001750 00000044670 00000000000 015660 0 ustar 00ihabunek ihabunek import logging
import urwid
from concurrent.futures import ThreadPoolExecutor
from toot import api, config, __version__
from .compose import StatusComposer
from .constants import PALETTE
from .entities import Status
from .overlays import ExceptionStackTrace, GotoMenu, Help, StatusSource, StatusLinks
from .overlays import StatusDeleteConfirmation
from .timeline import Timeline
from .utils import parse_content_links, show_media
logger = logging.getLogger(__name__)
urwid.set_encoding('UTF-8')
class Header(urwid.WidgetWrap):
def __init__(self, app, user):
self.app = app
self.user = user
self.text = urwid.Text("")
self.cols = urwid.Columns([
("pack", urwid.Text(('header_bold', 'toot'))),
("pack", urwid.Text(('header', ' | {}@{}'.format(user.username, app.instance)))),
("pack", self.text),
])
widget = urwid.AttrMap(self.cols, 'header')
widget = urwid.Padding(widget)
self._wrapped_widget = widget
def clear_text(self, text):
self.text.set_text("")
def set_text(self, text):
self.text.set_text(" | " + text)
class Footer(urwid.Pile):
def __init__(self):
self.status = urwid.Text("")
self.message = urwid.Text("")
return super().__init__([
urwid.AttrMap(self.status, "footer_status"),
urwid.AttrMap(self.message, "footer_message"),
])
def set_status(self, text):
self.status.set_text(text)
def clear_status(self, text):
self.status.set_text("")
def set_message(self, text):
self.message.set_text(text)
def set_error_message(self, text):
self.message.set_text(("footer_message_error", text))
def clear_message(self):
self.message.set_text("")
class TUI(urwid.Frame):
"""Main TUI frame."""
@classmethod
def create(cls, app, user):
"""Factory method, sets up TUI and an event loop."""
tui = cls(app, user)
loop = urwid.MainLoop(
tui,
palette=PALETTE,
event_loop=urwid.AsyncioEventLoop(),
unhandled_input=tui.unhandled_input,
)
tui.loop = loop
return tui
def __init__(self, app, user):
self.app = app
self.user = user
self.config = config.load_config()
self.loop = None # set in `create`
self.executor = ThreadPoolExecutor(max_workers=1)
self.timeline_generator = api.home_timeline_generator(app, user, limit=40)
# Show intro screen while toots are being loaded
self.body = self.build_intro()
self.header = Header(app, user)
self.footer = Footer()
self.footer.set_status("Loading...")
# Default max status length, updated on startup
self.max_toot_chars = 500
self.timeline = None
self.overlay = None
self.exception = None
super().__init__(self.body, header=self.header, footer=self.footer)
def run(self):
self.loop.set_alarm_in(0, lambda *args: self.async_load_instance())
self.loop.set_alarm_in(0, lambda *args: self.async_load_timeline(
is_initial=True, timeline_name="home"))
self.loop.run()
self.executor.shutdown(wait=False)
def build_intro(self):
font = urwid.font.Thin6x6Font()
# NB: Padding with width="clip" will convert the fixed BigText widget
# to a flow widget so it can be used in a Pile.
big_text = "Toot {}".format(__version__)
big_text = urwid.BigText(("intro_bigtext", big_text), font)
big_text = urwid.Padding(big_text, align="center", width="clip")
intro = urwid.Pile([
big_text,
urwid.Divider(),
urwid.Text([
"Maintained by ",
("intro_smalltext", "@ihabunek"),
" and contributors"
], align="center"),
urwid.Divider(),
urwid.Text(("intro_smalltext", "Loading toots..."), align="center"),
])
return urwid.Filler(intro)
def run_in_thread(self, fn, args=[], kwargs={}, done_callback=None, error_callback=None):
"""Runs `fn(*args, **kwargs)` asynchronously in a separate thread.
On completion calls `done_callback` if `fn` exited cleanly, or
`error_callback` if an exception was caught. Callback methods are
invoked in the main thread, not the thread in which `fn` is executed.
"""
def _default_error_callback(ex):
self.exception = ex
self.footer.set_error_message("An exeption occured, press E to view")
_error_callback = error_callback or _default_error_callback
def _done(future):
try:
result = future.result()
if done_callback:
# Use alarm to invoke callback in main thread
self.loop.set_alarm_in(0, lambda *args: done_callback(result))
except Exception as ex:
exception = ex
logger.exception(exception)
self.loop.set_alarm_in(0, lambda *args: _error_callback(exception))
future = self.executor.submit(fn, *args, **kwargs)
future.add_done_callback(_done)
return future
def connect_default_timeline_signals(self, timeline):
def _compose(*args):
self.show_compose()
def _delete(timeline, status):
if status.is_mine:
self.show_delete_confirmation(status)
def _reply(timeline, status):
self.show_compose(status)
def _source(timeline, status):
self.show_status_source(status)
def _links(timeline, status):
self.show_links(status)
def _media(timeline, status):
self.show_media(status)
def _menu(timeline, status):
self.show_context_menu(status)
urwid.connect_signal(timeline, "compose", _compose)
urwid.connect_signal(timeline, "delete", _delete)
urwid.connect_signal(timeline, "favourite", self.async_toggle_favourite)
urwid.connect_signal(timeline, "focus", self.refresh_footer)
urwid.connect_signal(timeline, "media", _media)
urwid.connect_signal(timeline, "menu", _menu)
urwid.connect_signal(timeline, "reblog", self.async_toggle_reblog)
urwid.connect_signal(timeline, "reply", _reply)
urwid.connect_signal(timeline, "source", _source)
urwid.connect_signal(timeline, "links", _links)
def build_timeline(self, name, statuses, local):
def _close(*args):
raise urwid.ExitMainLoop()
def _next(*args):
self.async_load_timeline(is_initial=False)
def _thread(timeline, status):
self.show_thread(status)
def _toggle_save(timeline, status):
if not timeline.name.startswith("#"):
return
hashtag = timeline.name[1:]
assert isinstance(local, bool), local
timelines = self.config.setdefault("timelines", {})
if hashtag in timelines:
del timelines[hashtag]
self.footer.set_message("#{} unpinned".format(hashtag))
else:
timelines[hashtag] = {"local": local}
self.footer.set_message("#{} pinned".format(hashtag))
self.loop.set_alarm_in(5, lambda *args: self.footer.clear_message())
config.save_config(self.config)
timeline = Timeline(name, statuses)
self.connect_default_timeline_signals(timeline)
urwid.connect_signal(timeline, "next", _next)
urwid.connect_signal(timeline, "close", _close)
urwid.connect_signal(timeline, "thread", _thread)
urwid.connect_signal(timeline, "save", _toggle_save)
return timeline
def make_status(self, status_data):
is_mine = self.user.username == status_data["account"]["acct"]
return Status(status_data, is_mine, self.app.instance)
def show_thread(self, status):
def _close(*args):
"""When thread is closed, go back to the main timeline."""
self.body = self.timeline
self.body.refresh_status_details()
self.refresh_footer(self.timeline)
# This is pretty fast, so it's probably ok to block while context is
# loaded, can be made async later if needed
context = api.context(self.app, self.user, status.original.id)
ancestors = [self.make_status(s) for s in context["ancestors"]]
descendants = [self.make_status(s) for s in context["descendants"]]
statuses = ancestors + [status] + descendants
focus = len(ancestors)
timeline = Timeline("thread", statuses, focus, is_thread=True)
self.connect_default_timeline_signals(timeline)
urwid.connect_signal(timeline, "close", _close)
self.body = timeline
self.refresh_footer(timeline)
def async_load_timeline(self, is_initial, timeline_name=None, local=None):
"""Asynchronously load a list of statuses."""
def _load_statuses():
self.footer.set_message("Loading statuses...")
try:
data = next(self.timeline_generator)
except StopIteration:
return []
finally:
self.footer.clear_message()
return [self.make_status(s) for s in data]
def _done_initial(statuses):
"""Process initial batch of statuses, construct a Timeline."""
self.timeline = self.build_timeline(timeline_name, statuses, local)
self.timeline.refresh_status_details() # Draw first status
self.refresh_footer(self.timeline)
self.body = self.timeline
def _done_next(statuses):
"""Process sequential batch of statuses, adds statuses to the
existing timeline."""
self.timeline.append_statuses(statuses)
return self.run_in_thread(_load_statuses,
done_callback=_done_initial if is_initial else _done_next)
def async_load_instance(self):
"""
Attempt to update max_toot_chars from instance data.
Does not work on vanilla Mastodon, works on Pleroma.
See: https://github.com/tootsuite/mastodon/issues/4915
"""
def _load_instance():
return api.get_instance(self.app.instance)
def _done(instance):
if "max_toot_chars" in instance:
self.max_toot_chars = instance["max_toot_chars"]
return self.run_in_thread(_load_instance, done_callback=_done)
def refresh_footer(self, timeline):
"""Show status details in footer."""
status, index, count = timeline.get_focused_status_with_counts()
self.footer.set_status([
("footer_status_bold", "[{}] ".format(timeline.name)),
] + ([status.id, " - status ", str(index + 1), " of ", str(count)]
if status else ["no focused status"]))
def show_status_source(self, status):
self.open_overlay(
widget=StatusSource(status),
title="Status source",
)
def show_links(self, status):
links = parse_content_links(status.data["content"]) if status else []
if links:
self.open_overlay(
widget=StatusLinks(links),
title="Status links",
options={"height": len(links) + 2},
)
def show_exception(self, exception):
self.open_overlay(
widget=ExceptionStackTrace(exception),
title="Unhandled Exception",
)
def show_compose(self, in_reply_to=None):
def _close(*args):
self.close_overlay()
def _post(timeline, *args):
self.post_status(*args)
composer = StatusComposer(self.max_toot_chars, in_reply_to)
urwid.connect_signal(composer, "close", _close)
urwid.connect_signal(composer, "post", _post)
self.open_overlay(composer, title="Compose status")
def show_goto_menu(self):
user_timelines = self.config.get("timelines", {})
menu = GotoMenu(user_timelines)
urwid.connect_signal(menu, "home_timeline",
lambda x: self.goto_home_timeline())
urwid.connect_signal(menu, "public_timeline",
lambda x, local: self.goto_public_timeline(local))
urwid.connect_signal(menu, "hashtag_timeline",
lambda x, tag, local: self.goto_tag_timeline(tag, local=local))
self.open_overlay(menu, title="Go to", options=dict(
align="center", width=("relative", 60),
valign="middle", height=9 + len(user_timelines),
))
def show_help(self):
self.open_overlay(Help(), title="Help")
def goto_home_timeline(self):
self.timeline_generator = api.home_timeline_generator(
self.app, self.user, limit=40)
promise = self.async_load_timeline(is_initial=True, timeline_name="home")
promise.add_done_callback(lambda *args: self.close_overlay())
def goto_public_timeline(self, local):
self.timeline_generator = api.public_timeline_generator(
self.app, self.user, local=local, limit=40)
promise = self.async_load_timeline(is_initial=True, timeline_name="public")
promise.add_done_callback(lambda *args: self.close_overlay())
def goto_tag_timeline(self, tag, local):
self.timeline_generator = api.tag_timeline_generator(
self.app, self.user, tag, local=local, limit=40)
promise = self.async_load_timeline(
is_initial=True, timeline_name="#{}".format(tag), local=local,
)
promise.add_done_callback(lambda *args: self.close_overlay())
def show_media(self, status):
urls = [m["url"] for m in status.original.data["media_attachments"]]
if urls:
show_media(urls)
def show_context_menu(self, status):
# TODO: show context menu
pass
def show_delete_confirmation(self, status):
def _delete(widget):
promise = self.async_delete_status(self.timeline, status)
promise.add_done_callback(lambda *args: self.close_overlay())
def _close(widget):
self.close_overlay()
widget = StatusDeleteConfirmation(status)
urwid.connect_signal(widget, "close", _close)
urwid.connect_signal(widget, "delete", _delete)
self.open_overlay(widget, title="Delete status?", options=dict(
align="center", width=("relative", 60),
valign="middle", height=5,
))
def post_status(self, content, warning, visibility, in_reply_to_id):
data = api.post_status(self.app, self.user, content,
spoiler_text=warning,
visibility=visibility,
in_reply_to_id=in_reply_to_id)
status = self.make_status(data)
# TODO: instead of this, fetch new items from the timeline?
self.timeline.prepend_status(status)
self.timeline.focus_status(status)
self.footer.set_message("Status posted {} \\o/".format(status.id))
self.close_overlay()
def async_toggle_favourite(self, timeline, status):
def _favourite():
logger.info("Favouriting {}".format(status))
api.favourite(self.app, self.user, status.id)
def _unfavourite():
logger.info("Unfavouriting {}".format(status))
api.unfavourite(self.app, self.user, status.id)
def _done(loop):
# Create a new Status with flipped favourited flag
new_data = status.data
new_data["favourited"] = not status.favourited
new_status = self.make_status(new_data)
timeline.update_status(new_status)
self.run_in_thread(
_unfavourite if status.favourited else _favourite,
done_callback=_done
)
def async_toggle_reblog(self, timeline, status):
def _reblog():
logger.info("Reblogging {}".format(status))
api.reblog(self.app, self.user, status.id)
def _unreblog():
logger.info("Unreblogging {}".format(status))
api.unreblog(self.app, self.user, status.id)
def _done(loop):
# Create a new Status with flipped reblogged flag
new_data = status.data
new_data["reblogged"] = not status.reblogged
new_status = self.make_status(new_data)
timeline.update_status(new_status)
self.run_in_thread(
_unreblog if status.reblogged else _reblog,
done_callback=_done
)
def async_delete_status(self, timeline, status):
def _delete():
api.delete_status(self.app, self.user, status.id)
def _done(loop):
timeline.remove_status(status)
return self.run_in_thread(_delete, done_callback=_done)
# --- Overlay handling -----------------------------------------------------
default_overlay_options = dict(
align="center", width=("relative", 80),
valign="middle", height=("relative", 80),
)
def open_overlay(self, widget, options={}, title=""):
top_widget = urwid.LineBox(widget, title=title)
bottom_widget = self.body
_options = self.default_overlay_options.copy()
_options.update(options)
self.overlay = urwid.Overlay(
top_widget,
bottom_widget,
**_options
)
self.body = self.overlay
def close_overlay(self):
self.body = self.overlay.bottom_w
self.overlay = None
# --- Keys -----------------------------------------------------------------
def unhandled_input(self, key):
# TODO: this should not be in unhandled input
if key in ('e', 'E'):
if self.exception:
self.show_exception(self.exception)
elif key in ('g', 'G'):
if not self.overlay:
self.show_goto_menu()
elif key in ('h', 'H'):
if not self.overlay:
self.show_help()
elif key == 'esc':
if self.overlay:
self.close_overlay()
elif self.timeline.name != "home":
# similar to goto_home_timeline() but without handling overlay (absent here)
self.timeline_generator = api.home_timeline_generator(
self.app, self.user, limit=40)
self.async_load_timeline(is_initial=True, timeline_name="home")
elif key in ('q', 'Q'):
if self.overlay:
self.close_overlay()
else:
raise urwid.ExitMainLoop()
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1597038933.0
toot-0.28.0/toot/tui/compose.py 0000644 0001750 0001750 00000011640 00000000000 016534 0 ustar 00ihabunek ihabunek import urwid
import logging
from .constants import VISIBILITY_OPTIONS
from .widgets import Button, EditBox
logger = logging.getLogger(__name__)
class StatusComposer(urwid.Frame):
"""
UI for compose and posting a status message.
"""
signals = ["close", "post"]
def __init__(self, max_chars, in_reply_to=None):
self.in_reply_to = in_reply_to
self.max_chars = max_chars
text = self.get_initial_text(in_reply_to)
self.content_edit = EditBox(
edit_text=text, edit_pos=len(text), multiline=True, allow_tab=True)
urwid.connect_signal(self.content_edit.edit, "change", self.text_changed)
self.char_count = urwid.Text(["0/{}".format(max_chars)])
self.cw_edit = None
self.cw_add_button = Button("Add content warning",
on_press=self.add_content_warning)
self.cw_remove_button = Button("Remove content warning",
on_press=self.remove_content_warning)
self.visibility = "public"
self.visibility_button = Button("Visibility: {}".format(self.visibility),
on_press=self.choose_visibility)
self.post_button = Button("Post", on_press=self.post)
self.cancel_button = Button("Cancel", on_press=self.close)
contents = list(self.generate_list_items())
self.walker = urwid.SimpleListWalker(contents)
self.listbox = urwid.ListBox(self.walker)
return super().__init__(self.listbox)
def get_initial_text(self, in_reply_to):
if not in_reply_to:
return ""
text = '@{} '.format(in_reply_to.account)
mentions = ['@{}'.format(m["acct"]) for m in in_reply_to.mentions]
if mentions:
text += '\n\n{}'.format(' '.join(mentions))
return text
def text_changed(self, edit, text):
count = self.max_chars - len(text)
text = "{}/{}".format(count, self.max_chars)
color = "warning" if count < 0 else ""
self.char_count.set_text((color, text))
def generate_list_items(self):
if self.in_reply_to:
yield urwid.Text(("gray", "Replying to {}".format(self.in_reply_to.account)))
yield urwid.AttrWrap(urwid.Divider("-"), "gray")
yield urwid.Text("Status message")
yield self.content_edit
yield self.char_count
yield urwid.Divider()
if self.cw_edit:
yield urwid.Text("Content warning")
yield self.cw_edit
yield urwid.Divider()
yield self.cw_remove_button
else:
yield self.cw_add_button
yield self.visibility_button
yield self.post_button
yield self.cancel_button
def refresh(self):
self.walker = urwid.SimpleListWalker(list(self.generate_list_items()))
self.listbox.body = self.walker
def choose_visibility(self, *args):
list_items = [urwid.Text("Choose status visibility:")]
for visibility, caption, description in VISIBILITY_OPTIONS:
text = "{} - {}".format(caption, description)
button = Button(text, on_press=self.set_visibility, user_data=visibility)
list_items.append(button)
self.walker = urwid.SimpleListWalker(list_items)
self.listbox.body = self.walker
# Initially focus currently chosen visibility
focus_map = {v[0]: n + 1 for n, v in enumerate(VISIBILITY_OPTIONS)}
focus = focus_map.get(self.visibility, 1)
self.walker.set_focus(focus)
def set_visibility(self, widget, visibility):
self.visibility = visibility
self.visibility_button.set_label("Visibility: {}".format(self.visibility))
self.refresh()
self.walker.set_focus(7 if self.cw_edit else 4)
def add_content_warning(self, button):
self.cw_edit = EditBox(multiline=True, allow_tab=True)
self.refresh()
self.walker.set_focus(4)
def remove_content_warning(self, button):
self.cw_edit = None
self.refresh()
self.walker.set_focus(3)
def set_error_message(self, msg):
self.footer = urwid.Text(("footer_message_error", msg))
def clear_error_message(self):
self.footer = None
def post(self, button):
self.clear_error_message()
# Don't lstrip content to avoid removing intentional leading whitespace
# However, do strip both sides to check if there is any content there
content = self.content_edit.edit_text.rstrip()
content = None if not content.strip() else content
warning = self.cw_edit.edit_text.rstrip() if self.cw_edit else ""
warning = None if not warning.strip() else warning
if not content:
self.set_error_message("Cannot post an empty message")
return
in_reply_to_id = self.in_reply_to.id if self.in_reply_to else None
self._emit("post", content, warning, self.visibility, in_reply_to_id)
def close(self, button):
self._emit("close")
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1601366677.0
toot-0.28.0/toot/tui/constants.py 0000644 0001750 0001750 00000003052 00000000000 017101 0 ustar 00ihabunek ihabunek # name, fg, bg, mono, fg_h, bg_h
PALETTE = [
# Components
('button', 'white', 'black'),
('button_focused', 'light gray', 'dark magenta'),
('columns_divider', 'white', 'dark blue'),
('content_warning', 'white', 'dark magenta'),
('editbox', 'white', 'black'),
('editbox_focused', 'white', 'dark magenta'),
('footer_message', 'dark green', ''),
('footer_message_error', 'light red', ''),
('footer_status', 'white', 'dark blue'),
('footer_status_bold', 'white, bold', 'dark blue'),
('header', 'white', 'dark blue'),
('header_bold', 'white,bold', 'dark blue'),
('intro_bigtext', 'yellow', ''),
('intro_smalltext', 'light blue', ''),
('poll_bar', 'white', 'dark blue'),
# Functional
('hashtag', 'light cyan,bold', ''),
('link', ',italics', ''),
('link_focused', ',italics', 'dark magenta'),
# Colors
('bold', ',bold', ''),
('blue', 'light blue', ''),
('blue_bold', 'light blue, bold', ''),
('blue_selected', 'white', 'dark blue'),
('cyan', 'dark cyan', ''),
('cyan_bold', 'dark cyan,bold', ''),
('gray', 'dark gray', ''),
('green', 'dark green', ''),
('green_selected', 'white,bold', 'dark green'),
('yellow', 'yellow', ''),
('yellow_bold', 'yellow,bold', ''),
('warning', 'light red', ''),
]
VISIBILITY_OPTIONS = [
("public", "Public", "Post to public timelines"),
("unlisted", "Unlisted", "Do not post to public timelines"),
("private", "Private", "Post to followers only"),
("direct", "Direct", "Post to mentioned users only"),
]
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1592249913.0
toot-0.28.0/toot/tui/entities.py 0000644 0001750 0001750 00000005173 00000000000 016717 0 ustar 00ihabunek ihabunek from collections import namedtuple
from .utils import parse_datetime
Author = namedtuple("Author", ["account", "display_name", "username"])
class Status:
"""
A wrapper around the Status entity data fetched from Mastodon.
https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md#status
Attributes
----------
reblog : Status or None
The reblogged status if it exists.
original : Status
If a reblog, the reblogged status, otherwise self.
"""
def __init__(self, data, is_mine, default_instance):
"""
Parameters
----------
data : dict
Status data as received from Mastodon.
https://docs.joinmastodon.org/api/entities/#status
is_mine : bool
Whether the status was created by the logged in user.
default_instance : str
The domain of the instance into which the user is logged in. Used to
create fully qualified account names for users on the same instance.
Mastodon only populates the name, not the domain.
"""
self.data = data
self.is_mine = is_mine
self.default_instance = default_instance
# This can be toggled by the user
self.show_sensitive = False
# TODO: clean up
self.id = self.data["id"]
self.account = self._get_account()
self.created_at = parse_datetime(data["created_at"])
self.author = self._get_author()
self.favourited = data.get("favourited", False)
self.reblogged = data.get("reblogged", False)
self.in_reply_to = data.get("in_reply_to_id")
self.url = data.get("url")
self.mentions = data.get("mentions")
self.reblog = self._get_reblog()
@property
def original(self):
return self.reblog or self
def _get_reblog(self):
reblog = self.data.get("reblog")
if not reblog:
return None
reblog_is_mine = self.is_mine and (
self.data["account"]["acct"] == reblog["account"]["acct"]
)
return Status(reblog, reblog_is_mine, self.default_instance)
def _get_author(self):
acct = self.data['account']['acct']
acct = acct if "@" in acct else "{}@{}".format(acct, self.default_instance)
return Author(acct, self.data['account']['display_name'], self.data['account']['username'])
def _get_account(self):
acct = self.data['account']['acct']
return acct if "@" in acct else "{}@{}".format(acct, self.default_instance)
def __repr__(self):
return "".format(self.id, self.account)
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1589196658.0
toot-0.28.0/toot/tui/overlays.py 0000644 0001750 0001750 00000014114 00000000000 016732 0 ustar 00ihabunek ihabunek import json
import traceback
import urwid
import webbrowser
from toot import __version__
from .utils import highlight_keys
from .widgets import Button, EditBox, SelectableText
class StatusSource(urwid.ListBox):
"""Shows status data, as returned by the server, as formatted JSON."""
def __init__(self, status):
source = json.dumps(status.data, indent=4)
lines = source.splitlines()
walker = urwid.SimpleFocusListWalker([
urwid.Text(line) for line in lines
])
super().__init__(walker)
class StatusLinks(urwid.ListBox):
"""Shows status links."""
def __init__(self, links):
def widget(url, title):
return Button(title or url, on_press=lambda btn: webbrowser.open(url))
walker = urwid.SimpleFocusListWalker(
[widget(url, title) for url, title in links]
)
super().__init__(walker)
class ExceptionStackTrace(urwid.ListBox):
"""Shows an exception stack trace."""
def __init__(self, ex):
lines = traceback.format_exception(etype=type(ex), value=ex, tb=ex.__traceback__)
walker = urwid.SimpleFocusListWalker([
urwid.Text(line) for line in lines
])
super().__init__(walker)
class StatusDeleteConfirmation(urwid.ListBox):
signals = ["delete", "close"]
def __init__(self, status):
yes = SelectableText("Yes, send it to heck")
no = SelectableText("No, I'll spare it for now")
urwid.connect_signal(yes, "click", lambda *args: self._emit("delete"))
urwid.connect_signal(no, "click", lambda *args: self._emit("close"))
walker = urwid.SimpleFocusListWalker([
urwid.AttrWrap(yes, "", "blue_selected"),
urwid.AttrWrap(no, "", "blue_selected"),
])
super().__init__(walker)
class GotoMenu(urwid.ListBox):
signals = [
"home_timeline",
"public_timeline",
"hashtag_timeline",
]
def __init__(self, user_timelines):
self.hash_edit = EditBox(caption="Hashtag: ")
actions = list(self.generate_actions(user_timelines))
walker = urwid.SimpleFocusListWalker(actions)
super().__init__(walker)
def get_hashtag(self):
return self.hash_edit.edit_text.strip()
def generate_actions(self, user_timelines):
def _home(button):
self._emit("home_timeline")
def _local_public(button):
self._emit("public_timeline", True)
def _global_public(button):
self._emit("public_timeline", False)
def _hashtag(local):
hashtag = self.get_hashtag()
if hashtag:
self._emit("hashtag_timeline", hashtag, local)
else:
self.set_focus(4)
def mk_on_press_user_hashtag(tag, local):
def on_press(btn):
self._emit("hashtag_timeline", tag, local)
return on_press
yield Button("Home timeline", on_press=_home)
for tag, cfg in user_timelines.items():
is_local = cfg["local"]
yield Button("#{}".format(tag) + (" (local)" if is_local else ""),
on_press=mk_on_press_user_hashtag(tag, is_local))
yield Button("Local public timeline", on_press=_local_public)
yield Button("Global public timeline", on_press=_global_public)
yield urwid.Divider()
yield self.hash_edit
yield Button("Local hashtag timeline", on_press=lambda x: _hashtag(True))
yield Button("Public hashtag timeline", on_press=lambda x: _hashtag(False))
class Help(urwid.Padding):
def __init__(self):
actions = list(self.generate_contents())
walker = urwid.SimpleListWalker(actions)
listbox = urwid.ListBox(walker)
super().__init__(listbox, left=1, right=1)
def generate_contents(self):
def h(text):
return highlight_keys(text, "cyan")
def link(text, url):
attr_map = {"link": "link_focused"}
text = SelectableText([text, ("link", url)])
urwid.connect_signal(text, "click", lambda t: webbrowser.open(url))
return urwid.AttrMap(text, "", attr_map)
yield urwid.Text(("yellow_bold", "toot {}".format(__version__)))
yield urwid.Divider()
yield urwid.Text(("bold", "General usage"))
yield urwid.Divider()
yield urwid.Text(h(" [Arrow keys] or [H/J/K/L] to move around and scroll content"))
yield urwid.Text(h(" [PageUp] and [PageDown] to scroll content"))
yield urwid.Text(h(" [Enter] or [Space] to activate buttons and menu options"))
yield urwid.Text(h(" [Esc] or [Q] to go back, close overlays, such as menus and this help text"))
yield urwid.Divider()
yield urwid.Text(("bold", "General keys"))
yield urwid.Divider()
yield urwid.Text(h(" [Q] - quit toot"))
yield urwid.Text(h(" [G] - go to - switch timelines"))
yield urwid.Text(h(" [P] - save/unsave (pin) current timeline"))
yield urwid.Text(h(" [H] - show this help"))
yield urwid.Divider()
yield urwid.Text(("bold", "Status keys"))
yield urwid.Divider()
yield urwid.Text("These commands are applied to the currently focused status.")
yield urwid.Divider()
yield urwid.Text(h(" [B] - Boost/unboost status"))
yield urwid.Text(h(" [C] - Compose new status"))
yield urwid.Text(h(" [F] - Favourite/unfavourite status"))
yield urwid.Text(h(" [R] - Reply to current status"))
yield urwid.Text(h(" [S] - Show text marked as sensitive"))
yield urwid.Text(h(" [T] - Show status thread (replies)"))
yield urwid.Text(h(" [L] - Show the status links"))
yield urwid.Text(h(" [U] - Show the status data in JSON as received from the server"))
yield urwid.Text(h(" [V] - Open status in default browser"))
yield urwid.Divider()
yield urwid.Text(("bold", "Links"))
yield urwid.Divider()
yield link("Documentation: ", "https://toot.readthedocs.io/")
yield link("Project home: ", "https://github.com/ihabunek/toot/")
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1601366677.0
toot-0.28.0/toot/tui/timeline.py 0000644 0001750 0001750 00000031134 00000000000 016675 0 ustar 00ihabunek ihabunek import logging
import urwid
import webbrowser
from toot.utils import format_content
from .utils import highlight_hashtags, parse_datetime, highlight_keys
from .widgets import SelectableText, SelectableColumns
logger = logging.getLogger("toot")
class Timeline(urwid.Columns):
"""
Displays a list of statuses to the left, and status details on the right.
"""
signals = [
"close", # Close thread
"compose", # Compose a new toot
"delete", # Delete own status
"favourite", # Favourite status
"focus", # Focus changed
"media", # Display media attachments
"menu", # Show a context menu
"next", # Fetch more statuses
"reblog", # Reblog status
"reply", # Compose a reply to a status
"source", # Show status source
"links", # Show status links
"thread", # Show thread for status
"save", # Save current timeline
]
def __init__(self, name, statuses, focus=0, is_thread=False):
self.name = name
self.is_thread = is_thread
self.statuses = statuses
self.status_list = self.build_status_list(statuses, focus=focus)
try:
self.status_details = StatusDetails(statuses[focus], is_thread)
except IndexError:
self.status_details = StatusDetails(None, is_thread)
super().__init__([
("weight", 40, self.status_list),
("weight", 0, urwid.AttrWrap(urwid.SolidFill("│"), "blue_selected")),
("weight", 60, urwid.Padding(self.status_details, left=1)),
])
def build_status_list(self, statuses, focus):
items = [self.build_list_item(status) for status in statuses]
walker = urwid.SimpleFocusListWalker(items)
walker.set_focus(focus)
urwid.connect_signal(walker, "modified", self.modified)
return urwid.ListBox(walker)
def build_list_item(self, status):
item = StatusListItem(status)
urwid.connect_signal(item, "click", lambda *args:
self._emit("menu", status))
return urwid.AttrMap(item, None, focus_map={
"blue": "green_selected",
"green": "green_selected",
"yellow": "green_selected",
"cyan": "green_selected",
None: "green_selected",
})
def get_focused_status(self):
try:
return self.statuses[self.status_list.body.focus]
except TypeError:
return None
def get_focused_status_with_counts(self):
"""Returns a tuple of:
* focused status
* focused status' index in the status list
* length of the status list
"""
return (
self.get_focused_status(),
self.status_list.body.focus,
len(self.statuses),
)
def modified(self):
"""Called when the list focus switches to a new status"""
status, index, count = self.get_focused_status_with_counts()
self.draw_status_details(status)
self._emit("focus")
def refresh_status_details(self):
"""Redraws the details of the focused status."""
status = self.get_focused_status()
self.draw_status_details(status)
def draw_status_details(self, status):
self.status_details = StatusDetails(status, self.is_thread)
self.contents[2] = urwid.Padding(self.status_details, left=1), ("weight", 60, False)
def keypress(self, size, key):
status = self.get_focused_status()
command = self._command_map[key]
if not status:
return super().keypress(size, key)
# If down is pressed on last status in list emit a signal to load more.
# TODO: Consider pre-loading statuses earlier
if command in [urwid.CURSOR_DOWN, urwid.CURSOR_PAGE_DOWN] \
and self.status_list.body.focus:
index = self.status_list.body.focus + 1
count = len(self.statuses)
if index >= count:
self._emit("next")
if key in ("b", "B"):
self._emit("reblog", status)
return
if key in ("c", "C"):
self._emit("compose")
return
if key in ("d", "D"):
self._emit("delete", status)
return
if key in ("f", "F"):
self._emit("favourite", status)
return
if key in ("m", "M"):
self._emit("media", status)
return
if key in ("q", "Q"):
self._emit("close")
return
if key == "esc" and self.is_thread:
self._emit("close")
return
if key in ("r", "R"):
self._emit("reply", status)
return
if key in ("s", "S"):
status.original.show_sensitive = True
self.refresh_status_details()
return
if key in ("l", "L"):
self._emit("links", status)
return
if key in ("t", "T"):
self._emit("thread", status)
return
if key in ("u", "U"):
self._emit("source", status)
return
if key in ("v", "V"):
if status.original.url:
webbrowser.open(status.original.url)
return
if key in ("p", "P"):
self._emit("save", status)
return
return super().keypress(size, key)
def append_status(self, status):
self.statuses.append(status)
self.status_list.body.append(self.build_list_item(status))
def prepend_status(self, status):
self.statuses.insert(0, status)
self.status_list.body.insert(0, self.build_list_item(status))
def append_statuses(self, statuses):
for status in statuses:
self.append_status(status)
def get_status_index(self, id):
# TODO: This is suboptimal, consider a better way
for n, status in enumerate(self.statuses):
if status.id == id:
return n
raise ValueError("Status with ID {} not found".format(id))
def focus_status(self, status):
index = self.get_status_index(status.id)
self.status_list.body.set_focus(index)
def update_status(self, status):
"""Overwrite status in list with the new instance and redraw."""
index = self.get_status_index(status.id)
assert self.statuses[index].id == status.id # Sanity check
# Update internal status list
self.statuses[index] = status
# Redraw list item
self.status_list.body[index] = self.build_list_item(status)
# Redraw status details if status is focused
if index == self.status_list.body.focus:
self.draw_status_details(status)
def remove_status(self, status):
index = self.get_status_index(status.id)
assert self.statuses[index].id == status.id # Sanity check
del(self.statuses[index])
del(self.status_list.body[index])
self.refresh_status_details()
class StatusDetails(urwid.Pile):
def __init__(self, status, in_thread):
"""
Parameters
----------
status : Status
The status to render.
in_thread : bool
Whether the status is rendered from a thread status list.
"""
self.in_thread = in_thread
reblogged_by = status.author if status and status.reblog else None
widget_list = list(self.content_generator(status.original, reblogged_by)
if status else ())
return super().__init__(widget_list)
def content_generator(self, status, reblogged_by):
if reblogged_by:
text = "♺ {} boosted".format(reblogged_by.display_name or reblogged_by.username)
yield ("pack", urwid.Text(("gray", text)))
yield ("pack", urwid.AttrMap(urwid.Divider("-"), "gray"))
if status.author.display_name:
yield ("pack", urwid.Text(("green", status.author.display_name)))
yield ("pack", urwid.Text(("yellow", status.author.account)))
yield ("pack", urwid.Divider())
if status.data["spoiler_text"]:
yield ("pack", urwid.Text(status.data["spoiler_text"]))
yield ("pack", urwid.Divider())
# Show content warning
if status.data["spoiler_text"] and not status.show_sensitive:
yield ("pack", urwid.Text(("content_warning", "Marked as sensitive. Press S to view.")))
else:
for line in format_content(status.data["content"]):
yield ("pack", urwid.Text(highlight_hashtags(line)))
media = status.data["media_attachments"]
if media:
for m in media:
yield ("pack", urwid.AttrMap(urwid.Divider("-"), "gray"))
yield ("pack", urwid.Text([("bold", "Media attachment"), " (", m["type"], ")"]))
if m["description"]:
yield ("pack", urwid.Text(m["description"]))
url = m.get("text_url") or m["url"]
yield ("pack", urwid.Text(("link", url)))
poll = status.data.get("poll")
if poll:
yield ("pack", urwid.Divider())
yield ("pack", self.build_linebox(self.poll_generator(poll)))
card = status.data.get("card")
if card:
yield ("pack", urwid.Divider())
yield ("pack", self.build_linebox(self.card_generator(card)))
application = status.data.get("application") or {}
application = application.get("name")
yield ("pack", urwid.AttrWrap(urwid.Divider("-"), "gray"))
yield ("pack", urwid.Text([
("gray", "⤶ {} ".format(status.data["replies_count"])),
("yellow" if status.reblogged else "gray", "♺ {} ".format(status.data["reblogs_count"])),
("yellow" if status.favourited else "gray", "★ {}".format(status.data["favourites_count"])),
("gray", " · {}".format(application) if application else ""),
]))
# Push things to bottom
yield ("weight", 1, urwid.SolidFill(" "))
options = [
"[B]oost",
"[D]elete" if status.is_mine else "",
"[F]avourite",
"[V]iew",
"[T]hread" if not self.in_thread else "",
"[L]inks",
"[R]eply",
"So[u]rce",
"[H]elp",
]
options = " ".join(o for o in options if o)
options = highlight_keys(options, "cyan_bold", "cyan")
yield ("pack", urwid.Text(options))
def build_linebox(self, contents):
contents = urwid.Pile(list(contents))
contents = urwid.Padding(contents, left=1, right=1)
return urwid.LineBox(contents)
def card_generator(self, card):
yield urwid.Text(("green", card["title"].strip()))
if card.get("author_name"):
yield urwid.Text(["by ", ("yellow", card["author_name"].strip())])
yield urwid.Text("")
if card["description"]:
yield urwid.Text(card["description"].strip())
yield urwid.Text("")
yield urwid.Text(("link", card["url"]))
def poll_generator(self, poll):
for option in poll["options"]:
perc = (round(100 * option["votes_count"] / poll["votes_count"])
if poll["votes_count"] else 0)
yield urwid.Text(option["title"])
yield urwid.ProgressBar("", "poll_bar", perc)
if poll["expired"]:
status = "Closed"
else:
expires_at = parse_datetime(poll["expires_at"]).strftime("%Y-%m-%d %H:%M")
status = "Closes on {}".format(expires_at)
status = "Poll · {} votes · {}".format(poll["votes_count"], status)
yield urwid.Text(("gray", status))
class StatusListItem(SelectableColumns):
def __init__(self, status):
created_at = status.created_at.strftime("%Y-%m-%d %H:%M")
favourited = ("yellow", "★") if status.original.favourited else " "
reblogged = ("yellow", "♺") if status.original.reblogged else " "
is_reblog = ("cyan", "♺") if status.reblog else " "
is_reply = ("cyan", "⤶") if status.original.in_reply_to else " "
return super().__init__([
("pack", SelectableText(("blue", created_at), wrap="clip")),
("pack", urwid.Text(" ")),
("pack", urwid.Text(favourited)),
("pack", urwid.Text(" ")),
("pack", urwid.Text(reblogged)),
("pack", urwid.Text(" ")),
urwid.Text(("green", status.original.account), wrap="clip"),
("pack", urwid.Text(is_reply)),
("pack", urwid.Text(is_reblog)),
("pack", urwid.Text(" ")),
])
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1586953249.0
toot-0.28.0/toot/tui/utils.py 0000644 0001750 0001750 00000005412 00000000000 016227 0 ustar 00ihabunek ihabunek from html.parser import HTMLParser
import re
import shutil
import subprocess
from datetime import datetime, timezone
HASHTAG_PATTERN = re.compile(r'(?>> highlight_keys("[P]rint [V]iew", "blue")
>>> [('blue', 'P'), 'rint ', ('blue', 'V'), 'iew']
"""
def _gen():
highlighted = False
for part in re.split("\\[|\\]", text):
if part:
if highlighted:
yield (high_attr, part) if high_attr else part
else:
yield (low_attr, part) if low_attr else part
highlighted = not highlighted
return list(_gen())
def highlight_hashtags(line, attr="hashtag"):
return [
(attr, p) if p.startswith("#") else p
for p in re.split(HASHTAG_PATTERN, line)
]
def show_media(paths):
"""
Attempt to open an image viewer to show given media files.
FIXME: This is not very thought out, but works for me.
Once settings are implemented, add an option for the user to configure their
prefered media viewer.
"""
viewer = None
potential_viewers = [
"feh",
"eog",
"display"
]
for v in potential_viewers:
viewer = shutil.which(v)
if viewer:
break
if not viewer:
raise Exception("Cannot find an image viewer")
subprocess.run([viewer] + paths)
class LinkParser(HTMLParser):
def reset(self):
super().reset()
self.links = []
def handle_starttag(self, tag, attrs):
if tag == "a":
href, title = None, None
for name, value in attrs:
if name == "href":
href = value
if name == "title":
title = value
if href:
self.links.append((href, title))
def parse_content_links(content):
"""Parse tags from status's `content` and return them as a list of
(href, title), where `title` may be None.
"""
parser = LinkParser()
parser.feed(content)
return parser.links[:]
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1601366677.0
toot-0.28.0/toot/tui/widgets.py 0000644 0001750 0001750 00000002430 00000000000 016532 0 ustar 00ihabunek ihabunek import urwid
class Clickable:
"""
Add a `click` signal which is sent when the item is activated or clicked.
TODO: make it work on widgets which have other signals.
"""
signals = ["click"]
def keypress(self, size, key):
if self._command_map[key] == urwid.ACTIVATE:
self._emit('click')
return
return key
def mouse_event(self, size, event, button, x, y, focus):
if button == 1:
self._emit('click')
class SelectableText(Clickable, urwid.Text):
_selectable = True
class SelectableColumns(Clickable, urwid.Columns):
_selectable = True
class EditBox(urwid.AttrWrap):
"""Styled edit box."""
def __init__(self, *args, **kwargs):
self.edit = urwid.Edit(*args, **kwargs)
return super().__init__(self.edit, "editbox", "editbox_focused")
class Button(urwid.AttrWrap):
"""Styled button."""
def __init__(self, *args, **kwargs):
button = urwid.Button(*args, **kwargs)
padding = urwid.Padding(button, width=len(args[0]) + 4)
return super().__init__(padding, "button", "button_focused")
def set_label(self, *args, **kwargs):
self.original_widget.original_widget.set_label(*args, **kwargs)
self.original_widget.width = len(args[0]) + 4
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1630168829.0
toot-0.28.0/toot/utils.py 0000644 0001750 0001750 00000005452 00000000000 015432 0 ustar 00ihabunek ihabunek # -*- coding: utf-8 -*-
import os
import re
import socket
import subprocess
import tempfile
import unicodedata
import warnings
from bs4 import BeautifulSoup
from toot.exceptions import ConsoleError
def str_bool(b):
"""Convert boolean to string, in the way expected by the API."""
return "true" if b else "false"
def get_text(html):
"""Converts html to text, strips all tags."""
# Ignore warnings made by BeautifulSoup, if passed something that looks like
# a file (e.g. a dot which matches current dict), it will warn that the file
# should be opened instead of passing a filename.
with warnings.catch_warnings():
warnings.simplefilter("ignore")
text = BeautifulSoup(html.replace(''', "'"), "html.parser").get_text()
return unicodedata.normalize('NFKC', text)
def parse_html(html):
"""Attempt to convert html to plain text while keeping line breaks.
Returns a list of paragraphs, each being a list of lines.
"""
paragraphs = re.split("?p[^>]*>", html)
# Convert
s to line breaks and remove empty paragraphs
paragraphs = [re.split("
", p) for p in paragraphs if p]
# Convert each line in each paragraph to plain text:
return [[get_text(l) for l in p] for p in paragraphs]
def format_content(content):
"""Given a Status contents in HTML, converts it into lines of plain text.
Returns a generator yielding lines of content.
"""
paragraphs = parse_html(content)
first = True
for paragraph in paragraphs:
if not first:
yield ""
for line in paragraph:
yield line
first = False
def domain_exists(name):
try:
socket.gethostbyname(name)
return True
except OSError:
return False
def assert_domain_exists(domain):
if not domain_exists(domain):
raise ConsoleError("Domain {} not found".format(domain))
EOF_KEY = "Ctrl-Z" if os.name == 'nt' else "Ctrl-D"
def multiline_input():
"""Lets user input multiple lines of text, terminated by EOF."""
lines = []
while True:
try:
lines.append(input())
except EOFError:
break
return "\n".join(lines).strip()
EDITOR_INPUT_INSTRUCTIONS = """
# Please enter your toot. Lines starting with '#' will be ignored, and an empty
# message aborts the post.
"""
def editor_input(editor, initial_text):
"""Lets user input text using an editor."""
initial_text = (initial_text or "") + EDITOR_INPUT_INSTRUCTIONS
with tempfile.NamedTemporaryFile() as f:
f.write(initial_text.encode())
f.flush()
subprocess.run([editor, f.name])
f.seek(0)
text = f.read().decode()
lines = text.strip().splitlines()
lines = (l for l in lines if not l.startswith("#"))
return "\n".join(lines)
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1630168829.0
toot-0.28.0/toot/wcstring.py 0000644 0001750 0001750 00000006073 00000000000 016132 0 ustar 00ihabunek ihabunek """
Utilities for dealing with string containing wide characters.
"""
import re
from wcwidth import wcwidth, wcswidth
def _wc_hard_wrap(line, length):
"""
Wrap text to length characters, breaking when target length is reached,
taking into account character width.
Used to wrap lines which cannot be wrapped on whitespace.
"""
chars = []
chars_len = 0
for char in line:
char_len = wcwidth(char)
if chars_len + char_len > length:
yield "".join(chars)
chars = []
chars_len = 0
chars.append(char)
chars_len += char_len
if chars:
yield "".join(chars)
def wc_wrap(text, length):
"""
Wrap text to given length, breaking on whitespace and taking into account
character width.
Meant for use on a single line or paragraph. Will destroy spacing between
words and paragraphs and any indentation.
"""
line_words = []
line_len = 0
words = re.split(r"\s+", text.strip())
for word in words:
word_len = wcswidth(word)
if line_words and line_len + word_len > length:
line = " ".join(line_words)
if line_len <= length:
yield line
else:
yield from _wc_hard_wrap(line, length)
line_words = []
line_len = 0
line_words.append(word)
line_len += word_len + 1 # add 1 to account for space between words
if line_words:
line = " ".join(line_words)
if line_len <= length:
yield line
else:
yield from _wc_hard_wrap(line, length)
def trunc(text, length):
"""
Truncates text to given length, taking into account wide characters.
If truncated, the last char is replaced by an elipsis.
"""
if length < 1:
raise ValueError("length should be 1 or larger")
# Remove whitespace first so no unneccesary truncation is done.
text = text.strip()
text_length = wcswidth(text)
if text_length <= length:
return text
# We cannot just remove n characters from the end since we don't know how
# wide these characters are and how it will affect text length.
# Use wcwidth to determine how many characters need to be truncated.
chars_to_truncate = 0
trunc_length = 0
for char in reversed(text):
chars_to_truncate += 1
trunc_length += wcwidth(char)
if text_length - trunc_length <= length:
break
# Additional char to make room for elipsis
n = chars_to_truncate + 1
return text[:-n].strip() + '…'
def pad(text, length):
"""Pads text to given length, taking into account wide characters."""
text_length = wcswidth(text)
if text_length < length:
return text + ' ' * (length - text_length)
return text
def fit_text(text, length):
"""Makes text fit the given length by padding or truncating it."""
text_length = wcswidth(text)
if text_length > length:
return trunc(text, length)
if text_length < length:
return pad(text, length)
return text
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1630178600.2211607
toot-0.28.0/toot.egg-info/ 0000755 0001750 0001750 00000000000 00000000000 015404 5 ustar 00ihabunek ihabunek ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1630178600.0
toot-0.28.0/toot.egg-info/PKG-INFO 0000644 0001750 0001750 00000002104 00000000000 016476 0 ustar 00ihabunek ihabunek Metadata-Version: 1.2
Name: toot
Version: 0.28.0
Summary: Mastodon CLI client
Home-page: https://github.com/ihabunek/toot/
Author: Ivan Habunek
Author-email: ivan@habunek.com
License: GPLv3
Project-URL: Documentation, https://toot.readthedocs.io/en/latest/
Project-URL: Issue tracker, https://github.com/ihabunek/toot/issues/
Description: Toot is a CLI and TUI tool for interacting with Mastodon instances from the
command line.
Allows posting text and media to the timeline, searching, following, muting
and blocking accounts and other actions.
Keywords: mastodon toot
Platform: UNKNOWN
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console :: Curses
Classifier: Environment :: Console
Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.4
Classifier: Programming Language :: Python :: 3.5
Classifier: Programming Language :: Python :: 3.6
Classifier: Programming Language :: Python :: 3.7
Requires-Python: >=3.4
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1630178600.0
toot-0.28.0/toot.egg-info/SOURCES.txt 0000644 0001750 0001750 00000001320 00000000000 017264 0 ustar 00ihabunek ihabunek MANIFEST.in
README.rst
setup.py
tests/__init__.py
tests/test_api.py
tests/test_auth.py
tests/test_config.py
tests/test_console.py
tests/test_utils.py
tests/test_version.py
tests/utils.py
toot/__init__.py
toot/api.py
toot/auth.py
toot/commands.py
toot/config.py
toot/console.py
toot/exceptions.py
toot/http.py
toot/logging.py
toot/output.py
toot/utils.py
toot/wcstring.py
toot.egg-info/PKG-INFO
toot.egg-info/SOURCES.txt
toot.egg-info/dependency_links.txt
toot.egg-info/entry_points.txt
toot.egg-info/requires.txt
toot.egg-info/top_level.txt
toot/tui/__init__.py
toot/tui/app.py
toot/tui/compose.py
toot/tui/constants.py
toot/tui/entities.py
toot/tui/overlays.py
toot/tui/timeline.py
toot/tui/utils.py
toot/tui/widgets.py ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1630178600.0
toot-0.28.0/toot.egg-info/dependency_links.txt 0000644 0001750 0001750 00000000001 00000000000 021452 0 ustar 00ihabunek ihabunek
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1630178600.0
toot-0.28.0/toot.egg-info/entry_points.txt 0000644 0001750 0001750 00000000054 00000000000 020701 0 ustar 00ihabunek ihabunek [console_scripts]
toot = toot.console:main
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1630178600.0
toot-0.28.0/toot.egg-info/requires.txt 0000644 0001750 0001750 00000000125 00000000000 020002 0 ustar 00ihabunek ihabunek beautifulsoup4<5.0,>=4.5.0
requests<3.0,>=2.13
urwid<3.0,>=2.0.0
wcwidth<2.0,>=0.1.7
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1630178600.0
toot-0.28.0/toot.egg-info/top_level.txt 0000644 0001750 0001750 00000000005 00000000000 020131 0 ustar 00ihabunek ihabunek toot