Introduction
Introduction Statistics Contact Development Disclaimer Help
Merge pull request #36 from ihabunek/new-config - toot - Unnamed repository; ed…
Log
Files
Refs
LICENSE
---
commit f976e7c818ca6d1da11116758ab056c20e36e5ca
parent a52fdf129bc2e4d6f87e682a5c70bed2660954e6
Author: Ivan Habunek <[email protected]>
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(-)
---
diff --git a/README.rst b/README.rst
@@ -91,12 +91,14 @@ Running ``toot <command> -h`` shows the documentation for t…
Authentication:
toot login Log in from the console, does NOT support two facto…
toot login_browser Log in using your browser, supports regular and two…
+ 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 Windo…
@@ -149,22 +151,13 @@ You will be redirected to your Mastodon instance to log i…
.. _instance: https://github.com/tootsuite/documentation/blob/master/Using-Mas…
-The application and user access tokens will be saved in two files in your home…
+The application and user access tokens will be saved in the configuration file…
-* ``~/.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 r…
-You can check whether you are currently logged in:
+To switch accounts, use ``toot activate``. Alternatively, most commands accept…
-.. 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 rem…
License
-------
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": "…
- 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
@@ -0,0 +1,121 @@
+import pytest
+
+from toot import User, App, config
+
+
[email protected]
+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': {
+ '[email protected]': {
+ 'access_token': 'mno',
+ 'instance': 'bar.social',
+ 'username': 'ihabunek'
+ }
+ },
+ 'active_user': '[email protected]',
+ }
+
+
+def test_extract_active_user_app(sample_config):
+ user, app = config.extract_user_app(sample_config, sample_config['active_u…
+
+ 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, …
+
+ # When active app does not exist for whatever reason
+ sample_config['users']['[email protected]']['instance'] = 'does-not-exist'
+ assert config.extract_user_app(sample_config, '[email protected]') == (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
@@ -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": {
+ "[email protected]": u("[email protected]"),
+ "[email protected]": u("[email protected]"),
+ },
+ "active_user": "[email protected]",
+ }
+
+ def mock_save(config):
+ assert config["users"] == {
+ "[email protected]": u("[email protected]")
+ }
+ 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", ["[email protected]"])
+
+ out, err = capsys.readouterr()
+ assert "✓ User [email protected] logged out" in out
+
+
+def test_activate(monkeypatch, capsys):
+ def mock_load():
+ return {
+ "users": {
+ "[email protected]": u("[email protected]"),
+ "[email protected]": u("[email protected]"),
+ },
+ "active_user": "[email protected]",
+ }
+
+ def mock_save(config):
+ assert config["users"] == {
+ "[email protected]": u("[email protected]"),
+ "[email protected]": u("[email protected]"),
+ }
+ assert config["active_user"] == "[email protected]"
+
+ monkeypatch.setattr(config, "load_config", mock_load)
+ monkeypatch.setattr(config, "save_config", mock_save)
+
+ console.run_command(None, None, "activate", ["[email protected]"])
+
+ out, err = capsys.readouterr()
+ assert "✓ User [email protected] active" in out
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
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'])
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…
from toot.exceptions import ConsoleError, NotFoundError
-from toot.output import print_out, print_instance, print_account, print_search…
+from toot.output import print_out, print_err, print_instance, print_account, p…
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>{}</yel…
- 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…
+
+ 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(us…
+
- 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):
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 lo…
+ 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
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
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 '[email protected]'",
+ "help": "account name, e.g. '[email protected]'",
})
instance_arg = (["-i", "--instance"], {
@@ -62,18 +62,24 @@ AUTH_COMMANDS = [
Command(
name="login_browser",
description="Log in using your browser, supports regular and two facto…
- 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, overrid…
+
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
@@ -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))
You are viewing proxied material from vernunftzentrum.de. The copyright of proxied material belongs to its original authors. Any comments or complaints in relation to proxied material should be directed to the original authors of the content concerned. Please see the disclaimer for more details.