Reimplement configuration to allow multiple logins

The configuration is now stored in a single json encoded file instead of
separate files.
This commit is contained in:
Ivan Habunek 2018-01-02 10:44:32 +01:00
parent 647a896ab5
commit 6a3c877270
No known key found for this signature in database
GPG Key ID: CDBD63C43A30BB95
11 changed files with 451 additions and 103 deletions

View File

@ -81,12 +81,14 @@ Running ``toot <command> -h`` shows the documentation for the given command.
Authentication: Authentication:
toot login Log in from the console, does NOT support two factor 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 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 logout Log out, delete stored access keys
toot auth Show stored credentials toot auth Show logged in accounts and instances
Read: Read:
toot whoami Display logged in user details toot whoami Display logged in user details
toot whois Display account details toot whois Display account details
toot instance Display instance details
toot search Search for users or hashtags toot search Search for users or hashtags
toot timeline Show recent items in your public timeline toot timeline Show recent items in your public timeline
toot curses An experimental timeline app (doesn't work on Windows) toot curses An experimental timeline app (doesn't work on Windows)
@ -139,22 +141,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 .. _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 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.
* ``~/.config/toot/user.cfg``
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:: Finally you can logout from an account by using ``toot logout``. This will remove the stored access tokens for that account.
toot auth
And you can logout which will remove the stored access tokens:
.. code-block::
toot logout
License License
------- -------

View File

@ -41,15 +41,17 @@ def test_create_app_registered(monkeypatch):
def test_create_user(monkeypatch): def test_create_user(monkeypatch):
app = App(4, 5, 6, 7) app = App(4, 5, 6, 7)
def assert_user(user): def assert_user(user, activate=True):
assert activate
assert isinstance(user, User) assert isinstance(user, User)
assert user.instance == app.instance assert user.instance == app.instance
assert user.username == 2 assert user.username == "foo"
assert user.access_token == 3 assert user.access_token == "abc"
monkeypatch.setattr(config, 'save_user', assert_user) 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) assert_user(user)

121
tests/test_config.py Normal file
View File

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

View File

@ -5,7 +5,7 @@ import re
from requests import Request from requests import Request
from toot import console, User, App from toot import config, console, User, App
from toot.exceptions import ConsoleError from toot.exceptions import ConsoleError
from tests.utils import MockResponse, Expectations from tests.utils import MockResponse, Expectations
@ -292,3 +292,63 @@ def test_whoami(monkeypatch, capsys):
assert "Followers: 5" in out assert "Followers: 5" in out
assert "Following: 9" in out assert "Following: 9" in out
assert "Statuses: 19" 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

View File

@ -30,6 +30,7 @@ class Expectations():
class MockResponse: class MockResponse:
def __init__(self, response_data={}, ok=True, is_redirect=False): def __init__(self, response_data={}, ok=True, is_redirect=False):
self.response_data = response_data self.response_data = response_data
self.content = response_data
self.ok = ok self.ok = ok
self.is_redirect = is_redirect self.is_redirect = is_redirect

View File

@ -26,8 +26,9 @@ def register_app(domain):
base_url = 'https://' + domain base_url = 'https://' + domain
app = App(domain, base_url, response['client_id'], response['client_secret']) app = App(domain, base_url, response['client_id'], response['client_secret'])
path = config.save_app(app) config.save_app(app)
print_out("Application tokens saved to: <green>{}</green>\n".format(path))
print_out("Application tokens saved.")
return app return app
@ -42,11 +43,16 @@ def create_app_interactive(instance=None):
return config.load_app(instance) or register_app(instance) return config.load_app(instance) or register_app(instance)
def create_user(app, email, access_token): def create_user(app, access_token):
user = User(app.instance, email, access_token) # Username is not yet known at this point, so fetch it from Mastodon
path = config.save_user(user) user = User(app.instance, None, access_token)
creds = api.verify_credentials(app, user)
print_out("Access token saved to: <green>{}</green>".format(path)) user = User(app.instance, creds['username'], access_token)
config.save_user(user, activate=True)
print_out("Access token saved to config at: <green>{}</green>".format(
config.get_config_file_path()))
return user return user
@ -68,7 +74,7 @@ def login_interactive(app, email=None):
except ApiError: except ApiError:
raise ConsoleError("Login failed") raise ConsoleError("Login failed")
return create_user(app, email, response['access_token']) return create_user(app, response['access_token'])
BROWSER_LOGIN_EXPLANATION = """ BROWSER_LOGIN_EXPLANATION = """
@ -81,7 +87,6 @@ which you need to paste here.
def login_browser_interactive(app): def login_browser_interactive(app):
url = api.get_browser_login_url(app) url = api.get_browser_login_url(app)
print_out(BROWSER_LOGIN_EXPLANATION) print_out(BROWSER_LOGIN_EXPLANATION)
print_out("This is the login URL:") print_out("This is the login URL:")
@ -99,9 +104,4 @@ def login_browser_interactive(app):
print_out("\nRequesting access token...") print_out("\nRequesting access token...")
response = api.request_access_token(app, authorization_code) response = api.request_access_token(app, authorization_code)
# TODO: user email is not available in this workflow, maybe change the User return create_user(app, response['access_token'])
# 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'])

View File

@ -9,7 +9,7 @@ from textwrap import TextWrapper
from toot import api, config from toot import api, config
from toot.auth import login_interactive, login_browser_interactive, create_app_interactive from toot.auth import login_interactive, login_browser_interactive, create_app_interactive
from toot.exceptions import ConsoleError, NotFoundError 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 from toot.utils import assert_domain_exists
@ -89,15 +89,21 @@ def post(app, user, args):
def auth(app, user, args): def auth(app, user, args):
if app and user: config_data = config.load_config()
print_out("You are logged in to <yellow>{}</yellow> as <yellow>{}</yellow>\n".format(
app.instance, user.username)) if not config_data["users"]:
print_out("User data: <green>{}</green>".format( print_out("You are not logged in to any accounts")
config.get_user_config_path())) return
print_out("App data: <green>{}</green>".format(
config.get_instance_config_path(app.instance))) active_user = config_data["active_user"]
else:
print_out("You are not logged in") 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): def login(app, user, args):
@ -117,9 +123,15 @@ def login_browser(app, user, args):
def logout(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): def upload(app, user, args):

View File

@ -1,78 +1,165 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import os import os
import json
from . import User, App from functools import wraps
# The dir where all toot configuration is stored from toot import User, App
CONFIG_DIR = os.environ['HOME'] + '/.config/toot/' from toot.config_legacy import load_legacy_config
from toot.exceptions import ConsoleError
# Subfolder where application access keys for various instances are stored from toot.output import print_out
INSTANCES_DIR = CONFIG_DIR + 'instances/'
# File in which user access token is stored
CONFIG_USER_FILE = CONFIG_DIR + 'user.cfg'
def get_instance_config_path(instance): # The file holding toot configuration
return INSTANCES_DIR + instance CONFIG_FILE = os.environ['HOME'] + '/.config/toot/config.json'
def get_user_config_path(): def get_config_file_path():
return CONFIG_USER_FILE return CONFIG_FILE
def _load(file, tuple_class): def user_id(user):
if not os.path.exists(file): return "{}@{}".format(user.username, user.instance)
return None
with open(file, 'r') as f:
lines = f.read().split()
try:
return tuple_class(*lines)
except TypeError:
return None
def _save(file, named_tuple): def make_config(path):
directory = os.path.dirname(file) """Creates a config file.
if not os.path.exists(directory):
os.makedirs(directory)
with open(file, 'w') as f: Attempts to load data from legacy config files if they exist.
values = [v for v in named_tuple] """
f.write("\n".join(values)) 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 <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): def load_app(instance):
path = get_instance_config_path(instance) config = load_config()
return _load(path, App) if instance in config['apps']:
return App(**config['apps'][instance])
def load_user(): def load_user(user_id, throw=False):
path = get_user_config_path() config = load_config()
return _load(path, User)
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): def modify_config(f):
path = get_instance_config_path(app.instance) @wraps(f)
_save(path, app) def wrapper(*args, **kwargs):
return path config = load_config()
config = f(config, *args, **kwargs)
save_config(config)
return config
return wrapper
def save_user(user): @modify_config
path = get_user_config_path() def save_app(config, app):
_save(path, user) assert isinstance(app, App)
return path
config['apps'][app.instance] = app._asdict()
return config
def delete_app(instance): @modify_config
path = get_instance_config_path(instance) def delete_app(config, app):
os.unlink(path) assert isinstance(app, App)
return path
config['apps'].pop(app.instance, None)
return config
def delete_user(): @modify_config
path = get_user_config_path() def save_user(config, user, activate=True):
os.unlink(path) assert isinstance(user, User)
return path
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

57
toot/config_legacy.py Normal file
View File

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

View File

@ -38,7 +38,7 @@ common_args = [
] ]
account_arg = (["account"], { 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"], { instance_arg = (["-i", "--instance"], {
@ -62,18 +62,24 @@ AUTH_COMMANDS = [
Command( Command(
name="login_browser", name="login_browser",
description="Log in using your browser, supports regular and two factor authentication", 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, require_auth=False,
), ),
Command( Command(
name="logout", name="logout",
description="Log out, delete stored access keys", description="Log out, delete stored access keys",
arguments=[], arguments=[account_arg],
require_auth=False, require_auth=False,
), ),
Command( Command(
name="auth", name="auth",
description="Show stored credentials", description="Show logged in accounts and instances",
arguments=[], arguments=[],
require_auth=False, require_auth=False,
), ),
@ -261,6 +267,10 @@ def get_argument_parser(name, command):
for args, kwargs in command.arguments + common_args: for args, kwargs in command.arguments + common_args:
parser.add_argument(*args, **kwargs) 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 return parser
@ -275,6 +285,12 @@ def run_command(app, user, name, args):
parser = get_argument_parser(name, command) parser = get_argument_parser(name, command)
parsed_args = parser.parse_args(args) 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): if command.require_auth and (not user or not app):
print_err("This command requires that you are logged in.") print_err("This command requires that you are logged in.")
print_err("Please run `toot login` first.") print_err("Please run `toot login` first.")
@ -305,8 +321,7 @@ def main():
if not command_name: if not command_name:
return print_usage() return print_usage()
user = config.load_user() user, app = config.get_active_user_app()
app = config.load_app(user.instance) if user else None
try: try:
run_command(app, user, command_name, args) run_command(app, user, command_name, args)

View File

@ -22,7 +22,7 @@ def log_request(request):
def log_response(response): def log_response(response):
if response.ok: if response.ok:
logger.debug("<<< \033[32m{}\033[0m".format(response)) 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: else:
logger.debug("<<< \033[31m{}\033[0m".format(response)) logger.debug("<<< \033[31m{}\033[0m".format(response))
logger.debug("<<< \033[31m{}\033[0m".format(response.content)) logger.debug("<<< \033[31m{}\033[0m".format(response.content))