Merge pull request #36 from ihabunek/new-config - toot - Unnamed repository; edit this file 'description' to name the repository.
(DIR) Log
(DIR) Files
(DIR) Refs
(DIR) LICENSE
---
(DIR) commit f976e7c818ca6d1da11116758ab056c20e36e5ca
(DIR) parent a52fdf129bc2e4d6f87e682a5c70bed2660954e6
(HTM) Author: Ivan Habunek <ivan@habunek.com>
Date: Sat, 13 Jan 2018 23:09:57 +0100
Merge pull request #36 from ihabunek/new-config
Reimplement configuration to allow multiple logins
Diffstat:
README.rst | 21 +++++++--------------
tests/test_auth.py | 10 ++++++----
tests/test_config.py | 121 +++++++++++++++++++++++++++++++
tests/test_console.py | 62 ++++++++++++++++++++++++++++++-
tests/utils.py | 1 +
toot/auth.py | 28 ++++++++++++++--------------
toot/commands.py | 36 +++++++++++++++++++++-----------
toot/config.py | 183 +++++++++++++++++++++++--------
toot/config_legacy.py | 57 +++++++++++++++++++++++++++++++
toot/console.py | 27 +++++++++++++++++++++------
toot/logging.py | 2 +-
11 files changed, 448 insertions(+), 100 deletions(-)
---
(DIR) diff --git a/README.rst b/README.rst
@@ -91,12 +91,14 @@ Running ``toot <command> -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/<name>`` - 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
-------
(DIR) diff --git 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)
(DIR) diff --git a/tests/test_config.py 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
(DIR) diff --git 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
(DIR) diff --git 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
(DIR) diff --git 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: <green>{}</green>\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)
+
+ user = User(app.instance, creds['username'], access_token)
+ config.save_user(user, activate=True)
- print_out("Access token saved to: <green>{}</green>".format(path))
+ print_out("Access token saved to config at: <green>{}</green>".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'])
(DIR) diff --git 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 <yellow>{}</yellow> as <yellow>{}</yellow>\n".format(
- app.instance, user.username))
- print_out("User data: <green>{}</green>".format(
- config.get_user_config_path()))
- print_out("App data: <green>{}</green>".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("* <green>{}</green> <yellow>{}</yellow>".format(uid, active_label))
+
+ path = config.get_config_file_path()
+ print_out("\nAuth tokens are stored in: <blue>{}</blue>".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("<green>✓ User {} logged out</green>".format(config.user_id(user)))
+
- print_out("<green>✓ You are now logged out.</green>")
+def activate(app, user, args):
+ user = config.load_user(args.account, throw=True)
+ config.activate_user(user)
+ print_out("<green>✓ User {} active</green>".format(config.user_id(user)))
def upload(app, user, args):
(DIR) diff --git 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/'
+from toot import User, App
+from toot.config_legacy import load_legacy_config
+from toot.exceptions import ConsoleError
+from toot.output import print_out
-# 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'
+# The file holding toot configuration
+CONFIG_FILE = os.environ['HOME'] + '/.config/toot/config.json'
-def get_instance_config_path(instance):
- return INSTANCES_DIR + instance
+def get_config_file_path():
+ return CONFIG_FILE
-def get_user_config_path():
- return CONFIG_USER_FILE
+def user_id(user):
+ return "{}@{}".format(user.username, user.instance)
-def _load(file, tuple_class):
- if not os.path.exists(file):
- return None
+def make_config(path):
+ """Creates a config file.
- with open(file, 'r') as f:
- lines = f.read().split()
- try:
- return tuple_class(*lines)
- except TypeError:
- return None
+ 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
-def _save(file, named_tuple):
- directory = os.path.dirname(file)
- if not os.path.exists(directory):
- os.makedirs(directory)
+ config = {
+ "apps": apps,
+ "users": users,
+ "active_user": active_user,
+ }
- with open(file, 'w') as f:
- values = [v for v in named_tuple]
- f.write("\n".join(values))
+ print_out("Creating config file at <blue>{}</blue>".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(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
-def load_user():
- path = get_user_config_path()
- return _load(path, User)
+@modify_config
+def delete_user(config, user):
+ assert isinstance(user, User)
-def save_app(app):
- path = get_instance_config_path(app.instance)
- _save(path, app)
- return path
+ config['users'].pop(user_id(user), None)
+ if config['active_user'] == user_id(user):
+ config['active_user'] = None
-def save_user(user):
- path = get_user_config_path()
- _save(path, user)
- return path
+ return config
-def delete_app(instance):
- path = get_instance_config_path(instance)
- os.unlink(path)
- return path
+@modify_config
+def activate_user(config, user):
+ assert isinstance(user, User)
+ config['active_user'] = user_id(user)
-def delete_user():
- path = get_user_config_path()
- os.unlink(path)
- return path
+ return config
(DIR) diff --git a/toot/config_legacy.py 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
(DIR) diff --git 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)
(DIR) diff --git 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))