diff --git a/README.rst b/README.rst index 1c6f58e..41b68a7 100644 --- a/README.rst +++ b/README.rst @@ -91,12 +91,14 @@ Running ``toot -h`` shows the documentation for the given command. Authentication: toot login Log in from the console, does NOT support two factor authentication toot login_browser Log in using your browser, supports regular and two factor authentication + toot activate Switch between logged in accounts. toot logout Log out, delete stored access keys - toot auth Show stored credentials + toot auth Show logged in accounts and instances Read: toot whoami Display logged in user details toot whois Display account details + toot instance Display instance details toot search Search for users or hashtags toot timeline Show recent items in your public timeline toot curses An experimental timeline app (doesn't work on Windows) @@ -149,22 +151,13 @@ You will be redirected to your Mastodon instance to log in and authorize toot to .. _instance: https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/List-of-Mastodon-instances.md -The application and user access tokens will be saved in two files in your home directory: +The application and user access tokens will be saved in the configuration file located at ``~/.config/toot/instances/config.json``. -* ``~/.config/toot/instances/`` - created for each mastodon instance once -* ``~/.config/toot/user.cfg`` +It's possible to be logged into **multiple accounts** at the same time. Just repeat the above process for another instance. You can see all logged in accounts by running ``toot auth``. The currently active account will have an **ACTIVE** flag next to it. -You can check whether you are currently logged in: +To switch accounts, use ``toot activate``. Alternatively, most commands accept a ``--using`` option which can be used to specify the account you wish to use just that one time. -.. code-block:: - - toot auth - -And you can logout which will remove the stored access tokens: - -.. code-block:: - - toot logout +Finally you can logout from an account by using ``toot logout``. This will remove the stored access tokens for that account. License ------- diff --git a/tests/test_auth.py b/tests/test_auth.py index 2d1d501..ef16204 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -41,15 +41,17 @@ def test_create_app_registered(monkeypatch): def test_create_user(monkeypatch): app = App(4, 5, 6, 7) - def assert_user(user): + def assert_user(user, activate=True): + assert activate assert isinstance(user, User) assert user.instance == app.instance - assert user.username == 2 - assert user.access_token == 3 + 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, 2, 3) + user = auth.create_user(app, 'abc') assert_user(user) diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..aaabd27 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,121 @@ +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 diff --git a/tests/test_console.py b/tests/test_console.py index 0c6ff31..c1b3e40 100644 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -5,7 +5,7 @@ import re from requests import Request -from toot import console, User, App +from toot import config, console, User, App from toot.exceptions import ConsoleError from tests.utils import MockResponse, Expectations @@ -292,3 +292,63 @@ def test_whoami(monkeypatch, capsys): assert "Followers: 5" in out assert "Following: 9" in out assert "Statuses: 19" in out + + +def u(user_id, access_token="abc"): + username, instance = user_id.split("@") + return { + "instance": instance, + "username": username, + "access_token": access_token, + } + + +def test_logout(monkeypatch, capsys): + def mock_load(): + return { + "users": { + "king@gizzard.social": u("king@gizzard.social"), + "lizard@wizard.social": u("lizard@wizard.social"), + }, + "active_user": "king@gizzard.social", + } + + def mock_save(config): + assert config["users"] == { + "lizard@wizard.social": u("lizard@wizard.social") + } + assert config["active_user"] is None + + monkeypatch.setattr(config, "load_config", mock_load) + monkeypatch.setattr(config, "save_config", mock_save) + + console.run_command(None, None, "logout", ["king@gizzard.social"]) + + out, err = capsys.readouterr() + assert "✓ User king@gizzard.social logged out" in out + + +def test_activate(monkeypatch, capsys): + def mock_load(): + return { + "users": { + "king@gizzard.social": u("king@gizzard.social"), + "lizard@wizard.social": u("lizard@wizard.social"), + }, + "active_user": "king@gizzard.social", + } + + def mock_save(config): + assert config["users"] == { + "king@gizzard.social": u("king@gizzard.social"), + "lizard@wizard.social": u("lizard@wizard.social"), + } + assert config["active_user"] == "lizard@wizard.social" + + monkeypatch.setattr(config, "load_config", mock_load) + monkeypatch.setattr(config, "save_config", mock_save) + + console.run_command(None, None, "activate", ["lizard@wizard.social"]) + + out, err = capsys.readouterr() + assert "✓ User lizard@wizard.social active" in out diff --git a/tests/utils.py b/tests/utils.py index 16b9332..d8242b5 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -30,6 +30,7 @@ class Expectations(): 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 diff --git a/toot/auth.py b/toot/auth.py index bdf67cd..87b6b23 100644 --- a/toot/auth.py +++ b/toot/auth.py @@ -26,8 +26,9 @@ def register_app(domain): base_url = 'https://' + domain app = App(domain, base_url, response['client_id'], response['client_secret']) - path = config.save_app(app) - print_out("Application tokens saved to: {}\n".format(path)) + config.save_app(app) + + print_out("Application tokens saved.") return app @@ -42,11 +43,16 @@ def create_app_interactive(instance=None): return config.load_app(instance) or register_app(instance) -def create_user(app, email, access_token): - user = User(app.instance, email, access_token) - path = config.save_user(user) +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) - print_out("Access token saved to: {}".format(path)) + 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 @@ -68,7 +74,7 @@ def login_interactive(app, email=None): except ApiError: raise ConsoleError("Login failed") - return create_user(app, email, response['access_token']) + return create_user(app, response['access_token']) BROWSER_LOGIN_EXPLANATION = """ @@ -81,7 +87,6 @@ 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:") @@ -99,9 +104,4 @@ def login_browser_interactive(app): print_out("\nRequesting access token...") response = api.request_access_token(app, authorization_code) - # TODO: user email is not available in this workflow, maybe change the User - # to store the username instead? Currently set to "unknown" since it's not - # used anywhere. - email = "unknown" - - return create_user(app, email, response['access_token']) + return create_user(app, response['access_token']) diff --git a/toot/commands.py b/toot/commands.py index 2e44bc5..084cfed 100644 --- a/toot/commands.py +++ b/toot/commands.py @@ -9,7 +9,7 @@ from textwrap import TextWrapper 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 +from toot.output import print_out, print_err, print_instance, print_account, print_search_results from toot.utils import assert_domain_exists @@ -89,15 +89,21 @@ def post(app, user, args): def auth(app, user, args): - if app and user: - print_out("You are logged in to {} as {}\n".format( - app.instance, user.username)) - print_out("User data: {}".format( - config.get_user_config_path())) - print_out("App data: {}".format( - config.get_instance_config_path(app.instance))) - else: - print_out("You are not logged in") + 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(app, user, args): @@ -117,9 +123,15 @@ def login_browser(app, user, args): def logout(app, user, args): - config.delete_user() + user = config.load_user(args.account, throw=True) + config.delete_user(user) + print_out("✓ User {} logged out".format(config.user_id(user))) - print_out("✓ You are now logged out.") + +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): diff --git a/toot/config.py b/toot/config.py index 6d503e7..2e14632 100644 --- a/toot/config.py +++ b/toot/config.py @@ -1,78 +1,165 @@ # -*- coding: utf-8 -*- import os +import json -from . import User, App +from functools import wraps -# The dir where all toot configuration is stored -CONFIG_DIR = os.environ['HOME'] + '/.config/toot/' - -# Subfolder where application access keys for various instances are stored -INSTANCES_DIR = CONFIG_DIR + 'instances/' - -# File in which user access token is stored -CONFIG_USER_FILE = CONFIG_DIR + 'user.cfg' +from toot import User, App +from toot.config_legacy import load_legacy_config +from toot.exceptions import ConsoleError +from toot.output import print_out -def get_instance_config_path(instance): - return INSTANCES_DIR + instance +# The file holding toot configuration +CONFIG_FILE = os.environ['HOME'] + '/.config/toot/config.json' -def get_user_config_path(): - return CONFIG_USER_FILE +def get_config_file_path(): + return CONFIG_FILE -def _load(file, tuple_class): - if not os.path.exists(file): - return None - - with open(file, 'r') as f: - lines = f.read().split() - try: - return tuple_class(*lines) - except TypeError: - return None +def user_id(user): + return "{}@{}".format(user.username, user.instance) -def _save(file, named_tuple): - directory = os.path.dirname(file) - if not os.path.exists(directory): - os.makedirs(directory) +def make_config(path): + """Creates a config file. - with open(file, 'w') as f: - values = [v for v in named_tuple] - f.write("\n".join(values)) + Attempts to load data from legacy config files if they exist. + """ + apps, user = load_legacy_config() + + apps = {a.instance: a._asdict() for a in apps} + users = {user_id(user): user._asdict()} if user else {} + active_user = user_id(user) if user else None + + config = { + "apps": apps, + "users": users, + "active_user": active_user, + } + + print_out("Creating config file at {}".format(path)) + with open(path, '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) + + +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): - path = get_instance_config_path(instance) - return _load(path, App) + config = load_config() + if instance in config['apps']: + return App(**config['apps'][instance]) -def load_user(): - path = get_user_config_path() - return _load(path, User) +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 save_app(app): - path = get_instance_config_path(app.instance) - _save(path, app) - return path +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 -def save_user(user): - path = get_user_config_path() - _save(path, user) - return path +@modify_config +def save_app(config, app): + assert isinstance(app, App) + + config['apps'][app.instance] = app._asdict() + + return config -def delete_app(instance): - path = get_instance_config_path(instance) - os.unlink(path) - return path +@modify_config +def delete_app(config, app): + assert isinstance(app, App) + + config['apps'].pop(app.instance, None) + + return config -def delete_user(): - path = get_user_config_path() - os.unlink(path) - return path +@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 diff --git a/toot/config_legacy.py b/toot/config_legacy.py new file mode 100644 index 0000000..87d9e7f --- /dev/null +++ b/toot/config_legacy.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- + +import os + +from . import User, App + +# The dir where all toot configuration is stored +CONFIG_DIR = os.environ['HOME'] + '/.config/toot/' + +# Subfolder where application access keys for various instances are stored +INSTANCES_DIR = CONFIG_DIR + 'instances/' + +# File in which user access token is stored +CONFIG_USER_FILE = CONFIG_DIR + 'user.cfg' + + +def load_user(path): + if not os.path.exists(path): + return None + + with open(path, 'r') as f: + lines = f.read().split() + return User(*lines) + + +def load_apps(path): + if not os.path.exists(path): + return [] + + for name in os.listdir(path): + with open(path + name) as f: + values = f.read().split() + yield App(*values) + + +def add_username(user, apps): + """When using broser login, username was not stored so look it up""" + if not user: + return None + + apps = [a for a in apps if a.instance == user.instance] + + if not apps: + return None + + from toot.api import verify_credentials + creds = verify_credentials(apps.pop(), user) + + return User(user.instance, creds['username'], user.access_token) + + +def load_legacy_config(): + apps = list(load_apps(INSTANCES_DIR)) + user = load_user(CONFIG_USER_FILE) + user = add_username(user, apps) + + return apps, user diff --git a/toot/console.py b/toot/console.py index f311b7a..1112ebd 100644 --- a/toot/console.py +++ b/toot/console.py @@ -38,7 +38,7 @@ common_args = [ ] account_arg = (["account"], { - "help": "account name, e.g. 'Gargron' or 'polymerwitch@toot.cat'", + "help": "account name, e.g. 'Gargron@mastodon.social'", }) instance_arg = (["-i", "--instance"], { @@ -62,18 +62,24 @@ AUTH_COMMANDS = [ Command( name="login_browser", description="Log in using your browser, supports regular and two factor authentication", - arguments=[instance_arg, email_arg], + arguments=[instance_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=[], + arguments=[account_arg], require_auth=False, ), Command( name="auth", - description="Show stored credentials", + description="Show logged in accounts and instances", arguments=[], require_auth=False, ), @@ -261,6 +267,10 @@ def get_argument_parser(name, command): for args, kwargs in command.arguments + common_args: parser.add_argument(*args, **kwargs) + # If the command requires auth, give an option to select account + if command.require_auth: + parser.add_argument("-u", "--using", help="the account to use, overrides active account") + return parser @@ -275,6 +285,12 @@ def run_command(app, user, name, args): 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.") @@ -305,8 +321,7 @@ def main(): if not command_name: return print_usage() - user = config.load_user() - app = config.load_app(user.instance) if user else None + user, app = config.get_active_user_app() try: run_command(app, user, command_name, args) diff --git a/toot/logging.py b/toot/logging.py index 91e7198..c4f5751 100644 --- a/toot/logging.py +++ b/toot/logging.py @@ -22,7 +22,7 @@ def log_request(request): def log_response(response): if response.ok: logger.debug("<<< \033[32m{}\033[0m".format(response)) - logger.debug("<<< \033[33m{}\033[0m".format(response.json())) + 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))