1
0
mirror of https://github.com/ihabunek/toot synced 2024-12-22 23:08:17 +01:00

Merge pull request #36 from ihabunek/new-config

Reimplement configuration to allow multiple logins
This commit is contained in:
Ivan Habunek 2018-01-13 23:09:57 +01:00 committed by GitHub
commit f976e7c818
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 451 additions and 103 deletions

View File

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

View File

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

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

View File

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

View File

@ -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)
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
@ -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'])

View File

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

View File

@ -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 <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():
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

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"], {
"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)

View File

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