././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1630178600.2211607 toot-0.28.0/0000755000175000017500000000000000000000000012725 5ustar00ihabunekihabunek././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1577956384.0 toot-0.28.0/MANIFEST.in0000644000175000017500000000003100000000000014455 0ustar00ihabunekihabunekrecursive-include tests *././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1630178600.2211607 toot-0.28.0/PKG-INFO0000644000175000017500000000210400000000000014017 0ustar00ihabunekihabunekMetadata-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 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1630165171.0 toot-0.28.0/README.rst0000644000175000017500000000356600000000000014426 0ustar00ihabunekihabunek============================ Toot - a Mastodon CLI client ============================ .. image:: https://raw.githubusercontent.com/ihabunek/toot/master/trumpet.png Toot is a CLI and TUI tool for interacting with Mastodon instances from the command line. .. image:: https://img.shields.io/travis/ihabunek/toot.svg?maxAge=3600&style=flat-square :target: https://travis-ci.org/ihabunek/toot .. image:: https://img.shields.io/badge/author-%40ihabunek-blue.svg?maxAge=3600&style=flat-square :target: https://mastodon.social/@ihabunek .. image:: https://img.shields.io/github/license/ihabunek/toot.svg?maxAge=3600&style=flat-square :target: https://opensource.org/licenses/GPL-3.0 .. image:: https://img.shields.io/pypi/v/toot.svg?maxAge=3600&style=flat-square :target: https://pypi.python.org/pypi/toot Resources --------- * Homepage: https://github.com/ihabunek/toot * Issues: https://github.com/ihabunek/toot/issues * Documentation: https://toot.readthedocs.io/en/latest/ * Mailing list for discussion, support and patches: https://lists.sr.ht/~ihabunek/toot-discuss * Informal discussion: #toot IRC channel on `libera.chat `_ Features -------- * Posting, replying, deleting statuses * Support for media uploads, spoiler text, sensitive content * Search by account or hash tag * Following, muting and blocking accounts * Simple switching between authenticated in Mastodon accounts Terminal User Interface ----------------------- toot includes a terminal user interface (TUI). Run it with ``toot tui``. .. image :: https://raw.githubusercontent.com/ihabunek/toot/master/docs/_static/tui_list.png .. image :: https://raw.githubusercontent.com/ihabunek/toot/master/docs/_static/tui_compose.png License ------- Copyright Ivan Habunek and contributors. Licensed under `GPLv3 `_, see `LICENSE `_. ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1630178600.2211607 toot-0.28.0/setup.cfg0000644000175000017500000000004600000000000014546 0ustar00ihabunekihabunek[egg_info] tag_build = tag_date = 0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1630178462.0 toot-0.28.0/setup.py0000644000175000017500000000277700000000000014454 0ustar00ihabunekihabunek#!/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', ], } ) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1630178600.2211607 toot-0.28.0/tests/0000755000175000017500000000000000000000000014067 5ustar00ihabunekihabunek././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1577956384.0 toot-0.28.0/tests/__init__.py0000644000175000017500000000000000000000000016166 0ustar00ihabunekihabunek././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1577956384.0 toot-0.28.0/tests/test_api.py0000644000175000017500000000370300000000000016254 0ustar00ihabunekihabunek# -*- 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) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1577956384.0 toot-0.28.0/tests/test_auth.py0000644000175000017500000000350200000000000016441 0ustar00ihabunekihabunek# -*- 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 # ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1577956384.0 toot-0.28.0/tests/test_config.py0000644000175000017500000001147300000000000016753 0ustar00ihabunekihabunekimport 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') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1630178109.0 toot-0.28.0/tests/test_console.py0000644000175000017500000004726000000000000017153 0ustar00ihabunekihabunek# -*- 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 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1577956384.0 toot-0.28.0/tests/test_utils.py0000644000175000017500000001340600000000000016644 0ustar00ihabunekihabunekfrom 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.", ] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1577956384.0 toot-0.28.0/tests/test_version.py0000644000175000017500000000036100000000000017165 0ustar00ihabunekihabunekimport 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 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1577956384.0 toot-0.28.0/tests/utils.py0000644000175000017500000000064400000000000015605 0ustar00ihabunekihabunek""" 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 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1630178600.2211607 toot-0.28.0/toot/0000755000175000017500000000000000000000000013712 5ustar00ihabunekihabunek././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1630178213.0 toot-0.28.0/toot/__init__.py0000644000175000017500000000056100000000000016025 0ustar00ihabunekihabunek# -*- 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' ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1630177344.0 toot-0.28.0/toot/api.py0000644000175000017500000001746300000000000015050 0ustar00ihabunekihabunek# -*- 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() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1630175686.0 toot-0.28.0/toot/auth.py0000644000175000017500000000655600000000000015241 0ustar00ihabunekihabunek# -*- 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']) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1630177275.0 toot-0.28.0/toot/commands.py0000644000175000017500000002540200000000000016070 0ustar00ihabunekihabunek# -*- 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() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1630168829.0 toot-0.28.0/toot/config.py0000644000175000017500000001017100000000000015531 0ustar00ihabunekihabunekimport 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 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1630177604.0 toot-0.28.0/toot/console.py0000644000175000017500000003650000000000000015732 0ustar00ihabunekihabunek# -*- 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 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1630168829.0 toot-0.28.0/toot/exceptions.py0000644000175000017500000000054600000000000016452 0ustar00ihabunekihabunekclass 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.""" ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1630168829.0 toot-0.28.0/toot/http.py0000644000175000017500000000477400000000000015257 0ustar00ihabunekihabunekfrom 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) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1630168829.0 toot-0.28.0/toot/logging.py0000644000175000017500000000230200000000000015707 0ustar00ihabunekihabunekfrom 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)) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1630168829.0 toot-0.28.0/toot/output.py0000644000175000017500000001426300000000000015632 0ustar00ihabunekihabunek# -*- 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 = "" 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) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1630178600.2211607 toot-0.28.0/toot/tui/0000755000175000017500000000000000000000000014513 5ustar00ihabunekihabunek././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1577956384.0 toot-0.28.0/toot/tui/__init__.py0000644000175000017500000000044700000000000016631 0ustar00ihabunekihabunekfrom 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, }) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1601366697.0 toot-0.28.0/toot/tui/app.py0000644000175000017500000004467000000000000015660 0ustar00ihabunekihabunekimport 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() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1597038933.0 toot-0.28.0/toot/tui/compose.py0000644000175000017500000001164000000000000016534 0ustar00ihabunekihabunekimport 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") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1601366677.0 toot-0.28.0/toot/tui/constants.py0000644000175000017500000000305200000000000017101 0ustar00ihabunekihabunek# 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"), ] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1592249913.0 toot-0.28.0/toot/tui/entities.py0000644000175000017500000000517300000000000016717 0ustar00ihabunekihabunekfrom 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) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1589196658.0 toot-0.28.0/toot/tui/overlays.py0000644000175000017500000001411400000000000016732 0ustar00ihabunekihabunekimport 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/") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1601366677.0 toot-0.28.0/toot/tui/timeline.py0000644000175000017500000003113400000000000016675 0ustar00ihabunekihabunekimport 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(" ")), ]) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1586953249.0 toot-0.28.0/toot/tui/utils.py0000644000175000017500000000541200000000000016227 0ustar00ihabunekihabunekfrom 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[:] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1601366677.0 toot-0.28.0/toot/tui/widgets.py0000644000175000017500000000243000000000000016532 0ustar00ihabunekihabunekimport 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 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1630168829.0 toot-0.28.0/toot/utils.py0000644000175000017500000000545200000000000015432 0ustar00ihabunekihabunek# -*- 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("]*>", 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) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1630168829.0 toot-0.28.0/toot/wcstring.py0000644000175000017500000000607300000000000016132 0ustar00ihabunekihabunek""" 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 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1630178600.2211607 toot-0.28.0/toot.egg-info/0000755000175000017500000000000000000000000015404 5ustar00ihabunekihabunek././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1630178600.0 toot-0.28.0/toot.egg-info/PKG-INFO0000644000175000017500000000210400000000000016476 0ustar00ihabunekihabunekMetadata-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 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1630178600.0 toot-0.28.0/toot.egg-info/SOURCES.txt0000644000175000017500000000132000000000000017264 0ustar00ihabunekihabunekMANIFEST.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././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1630178600.0 toot-0.28.0/toot.egg-info/dependency_links.txt0000644000175000017500000000000100000000000021452 0ustar00ihabunekihabunek ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1630178600.0 toot-0.28.0/toot.egg-info/entry_points.txt0000644000175000017500000000005400000000000020701 0ustar00ihabunekihabunek[console_scripts] toot = toot.console:main ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1630178600.0 toot-0.28.0/toot.egg-info/requires.txt0000644000175000017500000000012500000000000020002 0ustar00ihabunekihabunekbeautifulsoup4<5.0,>=4.5.0 requests<3.0,>=2.13 urwid<3.0,>=2.0.0 wcwidth<2.0,>=0.1.7 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1630178600.0 toot-0.28.0/toot.egg-info/top_level.txt0000644000175000017500000000000500000000000020131 0ustar00ihabunekihabunektoot