diff --git a/Makefile b/Makefile
index 438912b..4b09396 100644
--- a/Makefile
+++ b/Makefile
@@ -15,7 +15,7 @@ test:
coverage:
coverage erase
coverage run
- coverage html
+ coverage html --omit toot/tui/*
coverage report
clean :
diff --git a/changelog.yaml b/changelog.yaml
index 20961c2..93aed2c 100644
--- a/changelog.yaml
+++ b/changelog.yaml
@@ -1,3 +1,9 @@
+0.40.0:
+ date: TBA
+ changes:
+ - "Migrated to `click` for commandline arguments. BC should be mostly preserved, please report any issues."
+ - "Removed the deprecated `--disable-https` option for `login` and `login_cli`, pass the base URL instead"
+
0.39.0:
date: 2023-11-23
changes:
diff --git a/tests/integration/test_auth.py b/tests/integration/test_auth.py
new file mode 100644
index 0000000..446f8ad
--- /dev/null
+++ b/tests/integration/test_auth.py
@@ -0,0 +1,217 @@
+from typing import Any, Dict
+from unittest import mock
+from unittest.mock import MagicMock
+
+from toot import User, cli
+from toot.cli.base import Run
+
+# TODO: figure out how to test login
+
+
+EMPTY_CONFIG: Dict[Any, Any] = {
+ "apps": {},
+ "users": {},
+ "active_user": None
+}
+
+SAMPLE_CONFIG = {
+ "active_user": "frank@foo.social",
+ "apps": {
+ "foo.social": {
+ "base_url": "http://foo.social",
+ "client_id": "123",
+ "client_secret": "123",
+ "instance": "foo.social"
+ },
+ "bar.social": {
+ "base_url": "http://bar.social",
+ "client_id": "123",
+ "client_secret": "123",
+ "instance": "bar.social"
+ },
+ },
+ "users": {
+ "frank@foo.social": {
+ "access_token": "123",
+ "instance": "foo.social",
+ "username": "frank"
+ },
+ "frank@bar.social": {
+ "access_token": "123",
+ "instance": "bar.social",
+ "username": "frank"
+ },
+ }
+}
+
+
+def test_env(run: Run):
+ result = run(cli.env)
+ assert result.exit_code == 0
+ assert "toot" in result.stdout
+ assert "Python" in result.stdout
+
+
+@mock.patch("toot.config.load_config")
+def test_auth_empty(load_config: MagicMock, run: Run):
+ load_config.return_value = EMPTY_CONFIG
+ result = run(cli.auth)
+ assert result.exit_code == 0
+ assert result.stdout.strip() == "You are not logged in to any accounts"
+
+
+@mock.patch("toot.config.load_config")
+def test_auth_full(load_config: MagicMock, run: Run):
+ load_config.return_value = SAMPLE_CONFIG
+ result = run(cli.auth)
+ assert result.exit_code == 0
+ assert result.stdout.strip().startswith("Authenticated accounts:")
+ assert "frank@foo.social" in result.stdout
+ assert "frank@bar.social" in result.stdout
+
+
+# Saving config is mocked so we don't mess up our local config
+# TODO: could this be implemented using an auto-use fixture so we have it always
+# mocked?
+@mock.patch("toot.config.load_app")
+@mock.patch("toot.config.save_app")
+@mock.patch("toot.config.save_user")
+def test_login_cli(
+ save_user: MagicMock,
+ save_app: MagicMock,
+ load_app: MagicMock,
+ user: User,
+ run: Run,
+):
+ load_app.return_value = None
+
+ result = run(
+ cli.login_cli,
+ "--instance", "http://localhost:3000",
+ "--email", f"{user.username}@example.com",
+ "--password", "password",
+ )
+ assert result.exit_code == 0
+ assert "✓ Successfully logged in." in result.stdout
+
+ save_app.assert_called_once()
+ (app,) = save_app.call_args.args
+ assert app.instance == "localhost:3000"
+ assert app.base_url == "http://localhost:3000"
+ assert app.client_id
+ assert app.client_secret
+
+ save_user.assert_called_once()
+ (new_user,) = save_user.call_args.args
+ assert new_user.instance == "localhost:3000"
+ assert new_user.username == user.username
+ # access token will be different since this is a new login
+ assert new_user.access_token and new_user.access_token != user.access_token
+ assert save_user.call_args.kwargs == {"activate": True}
+
+
+@mock.patch("toot.config.load_app")
+@mock.patch("toot.config.save_app")
+@mock.patch("toot.config.save_user")
+def test_login_cli_wrong_password(
+ save_user: MagicMock,
+ save_app: MagicMock,
+ load_app: MagicMock,
+ user: User,
+ run: Run,
+):
+ load_app.return_value = None
+
+ result = run(
+ cli.login_cli,
+ "--instance", "http://localhost:3000",
+ "--email", f"{user.username}@example.com",
+ "--password", "wrong password",
+ )
+ assert result.exit_code == 1
+ assert result.stderr.strip() == "Error: Login failed"
+
+ save_app.assert_called_once()
+ (app,) = save_app.call_args.args
+ assert app.instance == "localhost:3000"
+ assert app.base_url == "http://localhost:3000"
+ assert app.client_id
+ assert app.client_secret
+
+ save_user.assert_not_called()
+
+
+@mock.patch("toot.config.load_config")
+@mock.patch("toot.config.delete_user")
+def test_logout(delete_user: MagicMock, load_config: MagicMock, run: Run):
+ load_config.return_value = SAMPLE_CONFIG
+
+ result = run(cli.logout, "frank@foo.social")
+ assert result.exit_code == 0
+ assert result.stdout.strip() == "✓ Account frank@foo.social logged out"
+ delete_user.assert_called_once_with(User("foo.social", "frank", "123"))
+
+
+@mock.patch("toot.config.load_config")
+def test_logout_not_logged_in(load_config: MagicMock, run: Run):
+ load_config.return_value = EMPTY_CONFIG
+
+ result = run(cli.logout)
+ assert result.exit_code == 1
+ assert result.stderr.strip() == "Error: You're not logged into any accounts"
+
+
+@mock.patch("toot.config.load_config")
+def test_logout_account_not_specified(load_config: MagicMock, run: Run):
+ load_config.return_value = SAMPLE_CONFIG
+
+ result = run(cli.logout)
+ assert result.exit_code == 1
+ assert result.stderr.startswith("Error: Specify account to log out")
+
+
+@mock.patch("toot.config.load_config")
+def test_logout_account_does_not_exist(load_config: MagicMock, run: Run):
+ load_config.return_value = SAMPLE_CONFIG
+
+ result = run(cli.logout, "banana")
+ assert result.exit_code == 1
+ assert result.stderr.startswith("Error: Account not found")
+
+
+@mock.patch("toot.config.load_config")
+@mock.patch("toot.config.activate_user")
+def test_activate(activate_user: MagicMock, load_config: MagicMock, run: Run):
+ load_config.return_value = SAMPLE_CONFIG
+
+ result = run(cli.activate, "frank@foo.social")
+ assert result.exit_code == 0
+ assert result.stdout.strip() == "✓ Account frank@foo.social activated"
+ activate_user.assert_called_once_with(User("foo.social", "frank", "123"))
+
+
+@mock.patch("toot.config.load_config")
+def test_activate_not_logged_in(load_config: MagicMock, run: Run):
+ load_config.return_value = EMPTY_CONFIG
+
+ result = run(cli.activate)
+ assert result.exit_code == 1
+ assert result.stderr.strip() == "Error: You're not logged into any accounts"
+
+
+@mock.patch("toot.config.load_config")
+def test_activate_account_not_given(load_config: MagicMock, run: Run):
+ load_config.return_value = SAMPLE_CONFIG
+
+ result = run(cli.activate)
+ assert result.exit_code == 1
+ assert result.stderr.startswith("Error: Specify account to activate")
+
+
+@mock.patch("toot.config.load_config")
+def test_activate_invalid_Account(load_config: MagicMock, run: Run):
+ load_config.return_value = SAMPLE_CONFIG
+
+ result = run(cli.activate, "banana")
+ assert result.exit_code == 1
+ assert result.stderr.startswith("Error: Account not found")
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 9dbb579..906a351 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,7 +1,7 @@
-from argparse import ArgumentTypeError
+import click
import pytest
-from toot.console import duration
+from toot.cli.validators import validate_duration
from toot.wcstring import wc_wrap, trunc, pad, fit_text
from toot.utils import urlencode_url
@@ -163,6 +163,9 @@ def test_wc_wrap_indented():
def test_duration():
+ def duration(value):
+ return validate_duration(None, None, value)
+
# Long hand
assert duration("1 second") == 1
assert duration("1 seconds") == 1
@@ -190,17 +193,17 @@ def test_duration():
assert duration("5d 10h 3m 1s") == 5 * 86400 + 10 * 3600 + 3 * 60 + 1
assert duration("5d10h3m1s") == 5 * 86400 + 10 * 3600 + 3 * 60 + 1
- with pytest.raises(ArgumentTypeError):
+ with pytest.raises(click.BadParameter):
duration("")
- with pytest.raises(ArgumentTypeError):
+ with pytest.raises(click.BadParameter):
duration("100")
# Wrong order
- with pytest.raises(ArgumentTypeError):
+ with pytest.raises(click.BadParameter):
duration("1m1d")
- with pytest.raises(ArgumentTypeError):
+ with pytest.raises(click.BadParameter):
duration("banana")
diff --git a/toot/api.py b/toot/api.py
index b2e82b7..b7136d6 100644
--- a/toot/api.py
+++ b/toot/api.py
@@ -140,7 +140,7 @@ def fetch_app_token(app):
return http.anon_post(f"{app.base_url}/oauth/token", json=json).json()
-def login(app, username, password):
+def login(app: App, username: str, password: str):
url = app.base_url + '/oauth/token'
data = {
@@ -152,16 +152,10 @@ def login(app, username, password):
'scope': SCOPES,
}
- response = http.anon_post(url, data=data, allow_redirects=False)
-
- # If auth fails, it redirects to the login page
- if response.is_redirect:
- raise AuthenticationError()
-
- return response.json()
+ return http.anon_post(url, data=data).json()
-def get_browser_login_url(app):
+def get_browser_login_url(app: App) -> str:
"""Returns the URL for manual log in via browser"""
return "{}/oauth/authorize/?{}".format(app.base_url, urlencode({
"response_type": "code",
@@ -171,7 +165,7 @@ def get_browser_login_url(app):
}))
-def request_access_token(app, authorization_code):
+def request_access_token(app: App, authorization_code: str):
url = app.base_url + '/oauth/token'
data = {
diff --git a/toot/auth.py b/toot/auth.py
index b9a0597..ef84652 100644
--- a/toot/auth.py
+++ b/toot/auth.py
@@ -1,18 +1,19 @@
-import sys
-import webbrowser
-
-from builtins import input
-from getpass import getpass
-
-from toot import api, config, DEFAULT_INSTANCE, User, App
+from toot import api, config, User, App
+from toot.entities import from_dict, Instance
from toot.exceptions import ApiError, ConsoleError
-from toot.output import print_out
from urllib.parse import urlparse
-def register_app(domain, base_url):
+def find_instance(base_url: str) -> Instance:
+ try:
+ instance = api.get_instance(base_url).json()
+ return from_dict(Instance, instance)
+ except Exception:
+ raise ConsoleError(f"Instance not found at {base_url}")
+
+
+def register_app(domain: str, base_url: str) -> App:
try:
- print_out("Registering application...")
response = api.create_app(base_url)
except ApiError:
raise ConsoleError("Registration failed.")
@@ -20,109 +21,54 @@ def register_app(domain, base_url):
app = App(domain, base_url, response['client_id'], response['client_secret'])
config.save_app(app)
- print_out("Application tokens saved.")
-
return app
-def create_app_interactive(base_url):
- if not base_url:
- print_out(f"Enter instance URL [{DEFAULT_INSTANCE}]: ", end="")
- base_url = input()
- if not base_url:
- base_url = DEFAULT_INSTANCE
-
- domain = get_instance_domain(base_url)
-
+def get_or_create_app(base_url: str) -> App:
+ instance = find_instance(base_url)
+ domain = _get_instance_domain(instance)
return config.load_app(domain) or register_app(domain, base_url)
-def get_instance_domain(base_url):
- print_out("Looking up instance info...")
-
- instance = api.get_instance(base_url).json()
-
- print_out(
- f"Found instance {instance['title']} "
- f"running Mastodon version {instance['version']}"
- )
-
- # Pleroma and its forks return an actual URI here, rather than a
- # domain name like Mastodon. This is contrary to the spec.¯
- # in that case, parse out the domain and return it.
- uri = instance["uri"]
- if uri.startswith("http"):
- return urlparse(uri).netloc
-
- return uri
- # NB: when updating to v2 instance endpoint, this field has been renamed to `domain`
-
-
-def create_user(app, access_token):
+def create_user(app: App, access_token: str) -> User:
# 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).json()
- user = User(app.instance, creds['username'], access_token)
+ 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
-def login_interactive(app, email=None):
- print_out("Log in to {}".format(app.instance))
-
- if email:
- print_out("Email: {}".format(email))
-
- while not email:
- email = input('Email: ')
-
- # Accept password piped from stdin, useful for testing purposes but not
- # documented so people won't get ideas. Otherwise prompt for password.
- if sys.stdin.isatty():
- password = getpass('Password: ')
- else:
- password = sys.stdin.read().strip()
- print_out("Password: read from stdin")
-
+def login_username_password(app: App, email: str, password: str) -> User:
try:
- print_out("Authenticating...")
response = api.login(app, email, password)
- except ApiError:
+ except Exception:
raise ConsoleError("Login failed")
- return create_user(app, response['access_token'])
+ return create_user(app, response["access_token"])
-BROWSER_LOGIN_EXPLANATION = """
-This authentication method requires you to log into your Mastodon instance
-in your browser, where you will be asked to authorize toot to access
-your account. When you do, you will be given an authorization code
-which you need to paste here.
-"""
+def login_auth_code(app: App, authorization_code: str) -> User:
+ try:
+ response = api.request_access_token(app, authorization_code)
+ except Exception:
+ raise ConsoleError("Login failed")
+
+ return create_user(app, response["access_token"])
-def login_browser_interactive(app):
- url = api.get_browser_login_url(app)
- print_out(BROWSER_LOGIN_EXPLANATION)
+def _get_instance_domain(instance: Instance) -> str:
+ """Extracts the instance domain name.
- print_out("This is the login URL:")
- print_out(url)
- print_out("")
+ Pleroma and its forks return an actual URI here, rather than a domain name
+ like Mastodon. This is contrary to the spec.¯ in that case, parse out the
+ domain and return it.
- yesno = input("Open link in default browser? [Y/n]")
- if not yesno or yesno.lower() == 'y':
- webbrowser.open(url)
-
- authorization_code = ""
- while not authorization_code:
- authorization_code = input("Authorization code: ")
-
- print_out("\nRequesting access token...")
- response = api.request_access_token(app, authorization_code)
-
- return create_user(app, response['access_token'])
+ TODO: when updating to v2 instance endpoint, this field has been renamed to
+ `domain`
+ """
+ if instance.uri.startswith("http"):
+ return urlparse(instance.uri).netloc
+ return instance.uri
diff --git a/toot/cli/__init__.py b/toot/cli/__init__.py
index c1d4a3b..2e6451c 100644
--- a/toot/cli/__init__.py
+++ b/toot/cli/__init__.py
@@ -1,5 +1,6 @@
from toot.cli.base import cli, Context # noqa
+from toot.cli.auth import *
from toot.cli.accounts import *
from toot.cli.lists import *
from toot.cli.post import *
diff --git a/toot/cli/accounts.py b/toot/cli/accounts.py
index a8c63c1..5cb66b9 100644
--- a/toot/cli/accounts.py
+++ b/toot/cli/accounts.py
@@ -4,9 +4,8 @@ import json as pyjson
from typing import BinaryIO, Optional
from toot import api
-from toot.cli.base import cli, json_option, Context, pass_context
+from toot.cli.base import PRIVACY_CHOICES, cli, json_option, Context, pass_context
from toot.cli.validators import validate_language
-from toot.console import PRIVACY_CHOICES
from toot.output import print_acct_list
diff --git a/toot/cli/auth.py b/toot/cli/auth.py
new file mode 100644
index 0000000..12d1a74
--- /dev/null
+++ b/toot/cli/auth.py
@@ -0,0 +1,143 @@
+import click
+import platform
+import sys
+import webbrowser
+
+from toot import api, config, __version__
+from toot.auth import get_or_create_app, login_auth_code, login_username_password
+from toot.cli.base import cli
+from toot.cli.validators import validate_instance
+
+
+instance_option = click.option(
+ "--instance", "-i", "base_url",
+ prompt="Enter instance URL",
+ default="https://mastodon.social",
+ callback=validate_instance,
+ help="""Domain or base URL of the instance to log into,
+ e.g. 'mastodon.social' or 'https://mastodon.social'""",
+)
+
+
+@cli.command()
+def auth():
+ """Show logged in accounts and instances"""
+ config_data = config.load_config()
+
+ if not config_data["users"]:
+ click.echo("You are not logged in to any accounts")
+ return
+
+ active_user = config_data["active_user"]
+
+ click.echo("Authenticated accounts:")
+ for uid, u in config_data["users"].items():
+ active_label = "ACTIVE" if active_user == uid else ""
+ uid = click.style(uid, fg="green")
+ active_label = click.style(active_label, fg="yellow")
+ click.echo(f"* {uid} {active_label}")
+
+ path = config.get_config_file_path()
+ path = click.style(path, "blue")
+ click.echo(f"\nAuth tokens are stored in: {path}")
+
+
+@cli.command()
+def env():
+ """Print environment information for inclusion in bug reports."""
+ click.echo(f"toot {__version__}")
+ click.echo(f"Python {sys.version}")
+ click.echo(platform.platform())
+
+
+@cli.command(name="login_cli")
+@instance_option
+@click.option("--email", "-e", help="Email address to log in with", prompt=True)
+@click.option("--password", "-p", hidden=True, prompt=True, hide_input=True)
+def login_cli(base_url: str, email: str, password: str):
+ """
+ Log into an instance from the console (not recommended)
+
+ Does NOT support two factor authentication, may not work on instances
+ other than Mastodon, mostly useful for scripting.
+ """
+ app = get_or_create_app(base_url)
+ login_username_password(app, email, password)
+
+ click.secho("✓ Successfully logged in.", fg="green")
+ click.echo("Access token saved to config at: ", nl=False)
+ click.secho(config.get_config_file_path(), fg="green")
+
+
+LOGIN_EXPLANATION = """This authentication method requires you to log into your
+Mastodon instance in your browser, where you will be asked to authorize toot to
+access your account. When you do, you will be given an authorization code which
+you need to paste here.""".replace("\n", " ")
+
+
+@cli.command()
+@instance_option
+def login(base_url: str):
+ """Log into an instance using your browser (recommended)"""
+ app = get_or_create_app(base_url)
+ url = api.get_browser_login_url(app)
+
+ click.echo(click.wrap_text(LOGIN_EXPLANATION))
+ click.echo("\nLogin URL:")
+ click.echo(url)
+
+ yesno = click.prompt("Open link in default browser? [Y/n]", default="Y", show_default=False)
+ if not yesno or yesno.lower() == 'y':
+ webbrowser.open(url)
+
+ authorization_code = ""
+ while not authorization_code:
+ authorization_code = click.prompt("Authorization code")
+
+ login_auth_code(app, authorization_code)
+
+ click.echo()
+ click.secho("✓ Successfully logged in.", fg="green")
+
+
+@cli.command()
+@click.argument("account", required=False)
+def logout(account: str):
+ """Log out of ACCOUNT, delete stored access keys"""
+ accounts = _get_accounts_list()
+
+ if not account:
+ raise click.ClickException(f"Specify account to log out:\n{accounts}")
+
+ user = config.load_user(account)
+
+ if not user:
+ raise click.ClickException(f"Account not found. Logged in accounts:\n{accounts}")
+
+ config.delete_user(user)
+ click.secho(f"✓ Account {account} logged out", fg="green")
+
+
+@cli.command()
+@click.argument("account", required=False)
+def activate(account: str):
+ """Switch to logged in ACCOUNT."""
+ accounts = _get_accounts_list()
+
+ if not account:
+ raise click.ClickException(f"Specify account to activate:\n{accounts}")
+
+ user = config.load_user(account)
+
+ if not user:
+ raise click.ClickException(f"Account not found. Logged in accounts:\n{accounts}")
+
+ config.activate_user(user)
+ click.secho(f"✓ Account {account} activated", fg="green")
+
+
+def _get_accounts_list() -> str:
+ accounts = config.load_config()["users"].keys()
+ if not accounts:
+ raise click.ClickException("You're not logged into any accounts")
+ return "\n".join([f"* {acct}" for acct in accounts])
diff --git a/toot/cli/base.py b/toot/cli/base.py
index b26f3e3..c86b531 100644
--- a/toot/cli/base.py
+++ b/toot/cli/base.py
@@ -1,12 +1,29 @@
-import logging
-import sys
import click
+import logging
+import os
+import sys
+from click.testing import Result
from functools import wraps
from toot import App, User, config, __version__
from typing import Callable, Concatenate, NamedTuple, Optional, ParamSpec, TypeVar
+PRIVACY_CHOICES = ["public", "unlisted", "private"]
+VISIBILITY_CHOICES = ["public", "unlisted", "private", "direct"]
+
+DURATION_EXAMPLES = """e.g. "1 day", "2 hours 30 minutes", "5 minutes 30
+seconds" or any combination of above. Shorthand: "1d", "2h30m", "5m30s\""""
+
+
+# Type alias for run commands
+Run = Callable[..., Result]
+
+
+def get_default_visibility() -> str:
+ return os.getenv("TOOT_POST_VISIBILITY", "public")
+
+
# Tweak the Click context
# https://click.palletsprojects.com/en/8.1.x/api/#context
CONTEXT = dict(
diff --git a/toot/cli/post.py b/toot/cli/post.py
index 92b839e..d19fe41 100644
--- a/toot/cli/post.py
+++ b/toot/cli/post.py
@@ -8,8 +8,8 @@ from typing import Optional, Tuple
from toot import api
from toot.cli.base import cli, json_option, pass_context, Context
+from toot.cli.base import DURATION_EXAMPLES, VISIBILITY_CHOICES, get_default_visibility
from toot.cli.validators import validate_duration, validate_language
-from toot.console import DURATION_EXAMPLES, VISIBILITY_CHOICES, get_default_visibility
from toot.utils import EOF_KEY, delete_tmp_status_file, editor_input, multiline_input
from toot.utils.datetime import parse_datetime
diff --git a/toot/cli/statuses.py b/toot/cli/statuses.py
index d675439..1cc755b 100644
--- a/toot/cli/statuses.py
+++ b/toot/cli/statuses.py
@@ -1,8 +1,8 @@
import click
from toot import api
-from toot.console import VISIBILITY_CHOICES, get_default_visibility
from toot.cli.base import cli, json_option, Context, pass_context
+from toot.cli.base import VISIBILITY_CHOICES, get_default_visibility
from toot.output import print_table
diff --git a/toot/cli/validators.py b/toot/cli/validators.py
index 5d52d20..cfdd097 100644
--- a/toot/cli/validators.py
+++ b/toot/cli/validators.py
@@ -1,8 +1,11 @@
import click
import re
+from click import Context
+from typing import Optional
-def validate_language(ctx, param, value):
+
+def validate_language(ctx: Context, param: str, value: Optional[str]):
if value is None:
return None
@@ -13,7 +16,7 @@ def validate_language(ctx, param, value):
raise click.BadParameter("Language should be a two letter abbreviation.")
-def validate_duration(ctx, param, value: str) -> int:
+def validate_duration(ctx: Context, param: str, value: Optional[str]) -> Optional[int]:
if value is None:
return None
@@ -43,3 +46,15 @@ def validate_duration(ctx, param, value: str) -> int:
raise click.BadParameter("Empty duration")
return duration
+
+
+def validate_instance(ctx: click.Context, param: str, value: Optional[str]):
+ """
+ Instance can be given either as a base URL or the domain name.
+ Return the base URL.
+ """
+ if not value:
+ return None
+
+ value = value.rstrip("/")
+ return value if value.startswith("http") else f"https://{value}"
diff --git a/toot/config.py b/toot/config.py
index 077e098..98ee6d8 100644
--- a/toot/config.py
+++ b/toot/config.py
@@ -3,6 +3,7 @@ import os
from functools import wraps
from os.path import dirname, join
+from typing import Optional
from toot import User, App, get_config_dir
from toot.exceptions import ConsoleError
@@ -85,7 +86,7 @@ def get_user_app(user_id):
return extract_user_app(load_config(), user_id)
-def load_app(instance):
+def load_app(instance: str) -> Optional[App]:
config = load_config()
if instance in config['apps']:
return App(**config['apps'][instance])