From d5b5c899964c9f309dcbbbcbf6a7187c7907e4a3 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Tue, 7 Mar 2023 10:37:03 +0100 Subject: [PATCH 1/2] Add support for custom instance domains The instance domain can be different from their base url, for example the instance at https://social.vivaldi.net uses the vivaldi.net domain, sans 'social'. This commit requires the user to provide the base url of the instance, instead of domain name. The domain is then fetched from the server. fixes #217 --- toot/__init__.py | 2 +- toot/api.py | 8 ++++---- toot/auth.py | 42 +++++++++++++++++++++++++----------------- toot/commands.py | 22 +++++++++++++--------- toot/tui/app.py | 2 +- toot/utils/__init__.py | 26 ++++++++++++++++++++++++++ 6 files changed, 70 insertions(+), 32 deletions(-) diff --git a/toot/__init__.py b/toot/__init__.py index 05e15ac..b3f3d1a 100644 --- a/toot/__init__.py +++ b/toot/__init__.py @@ -5,7 +5,7 @@ __version__ = '0.35.0' App = namedtuple('App', ['instance', 'base_url', 'client_id', 'client_secret']) User = namedtuple('User', ['instance', 'username', 'access_token']) -DEFAULT_INSTANCE = 'mastodon.social' +DEFAULT_INSTANCE = 'https://mastodon.social' CLIENT_NAME = 'toot - a Mastodon CLI client' CLIENT_WEBSITE = 'https://github.com/ihabunek/toot' diff --git a/toot/api.py b/toot/api.py index 3bb2c89..672849b 100644 --- a/toot/api.py +++ b/toot/api.py @@ -28,8 +28,8 @@ def _tag_action(app, user, tag_name, action): return http.post(app, user, url).json() -def create_app(domain, scheme='https'): - url = f"{scheme}://{domain}/api/v1/apps" +def create_app(base_url): + url = f"{base_url}/api/v1/apps" json = { 'client_name': CLIENT_NAME, @@ -504,6 +504,6 @@ def clear_notifications(app, user): http.post(app, user, '/api/v1/notifications/clear') -def get_instance(domain, scheme="https"): - url = f"{scheme}://{domain}/api/v1/instance" +def get_instance(base_url): + url = f"{base_url}/api/v1/instance" return http.anon_get(url).json() diff --git a/toot/auth.py b/toot/auth.py index 05b61b6..0ee2bac 100644 --- a/toot/auth.py +++ b/toot/auth.py @@ -9,21 +9,13 @@ from toot.exceptions import ApiError, ConsoleError from toot.output import print_out -def register_app(domain, scheme='https'): - print_out("Looking up instance info...") - instance = api.get_instance(domain, scheme) - - print_out("Found instance {} running Mastodon version {}".format( - instance['title'], instance['version'])) - +def register_app(domain, base_url): try: print_out("Registering application...") - response = api.create_app(domain, scheme) + response = api.create_app(base_url) except ApiError: raise ConsoleError("Registration failed.") - base_url = scheme + '://' + domain - app = App(domain, base_url, response['client_id'], response['client_secret']) config.save_app(app) @@ -32,14 +24,30 @@ def register_app(domain, scheme='https'): return app -def create_app_interactive(instance=None, scheme='https'): - if not instance: - print_out("Choose an instance [{}]: ".format(DEFAULT_INSTANCE), end="") - instance = input() - if not instance: - instance = DEFAULT_INSTANCE +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 - return config.load_app(instance) or register_app(instance, scheme) + domain = get_instance_domain(base_url) + + 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) + + print_out( + f"Found instance {instance['title']} " + f"running Mastodon version {instance['version']}" + ) + + # NB: when updating to v2 instance endpoint, this field has been renamed to `domain` + return instance["uri"] def create_user(app, access_token): diff --git a/toot/commands.py b/toot/commands.py index e21f5b3..18e7be6 100644 --- a/toot/commands.py +++ b/toot/commands.py @@ -10,7 +10,7 @@ from toot.output import (print_out, print_instance, print_account, print_acct_li print_search_results, print_timeline, print_notifications, print_tag_list) from toot.tui.utils import parse_datetime -from toot.utils import delete_tmp_status_file, editor_input, multiline_input, EOF_KEY +from toot.utils import args_get_instance, delete_tmp_status_file, editor_input, multiline_input, EOF_KEY def get_timeline_generator(app, user, args): @@ -305,7 +305,8 @@ def update_account(app, user, args): def login_cli(app, user, args): - app = create_app_interactive(instance=args.instance, scheme=args.scheme) + base_url = args_get_instance(args.instance, args.scheme) + app = create_app_interactive(base_url) login_interactive(app, args.email) print_out() @@ -313,7 +314,8 @@ def login_cli(app, user, args): def login(app, user, args): - app = create_app_interactive(instance=args.instance, scheme=args.scheme) + base_url = args_get_instance(args.instance, args.scheme) + app = create_app_interactive(base_url) login_browser_interactive(app) print_out() @@ -452,17 +454,19 @@ def whois(app, user, args): def instance(app, user, args): - name = args.instance or (app and app.instance) - if not name: - raise ConsoleError("Please specify instance name.") + default = app.base_url if app else None + base_url = args_get_instance(args.instance, args.scheme, default) + + if not base_url: + raise ConsoleError("Please specify an instance.") try: - instance = api.get_instance(name, args.scheme) + instance = api.get_instance(base_url) print_instance(instance) except ApiError: raise ConsoleError( - "Instance not found at {}.\n" - "The given domain probably does not host a Mastodon instance.".format(name) + f"Instance not found at {base_url}.\n" + "The given domain probably does not host a Mastodon instance." ) diff --git a/toot/tui/app.py b/toot/tui/app.py index b1d885a..330035b 100644 --- a/toot/tui/app.py +++ b/toot/tui/app.py @@ -336,7 +336,7 @@ class TUI(urwid.Frame): See: https://github.com/mastodon/mastodon/issues/19328 """ def _load_instance(): - return api.get_instance(self.app.instance) + return api.get_instance(self.app.base_url) def _done(instance): if "max_toot_chars" in instance: diff --git a/toot/utils/__init__.py b/toot/utils/__init__.py index 8a39fd2..e8103ac 100644 --- a/toot/utils/__init__.py +++ b/toot/utils/__init__.py @@ -160,3 +160,29 @@ def _use_existing_tmp_file(tmp_path) -> bool: def drop_empty_values(data: Dict) -> Dict: """Remove keys whose values are null""" return {k: v for k, v in data.items() if v is not None} + + +def args_get_instance(instance, scheme, default=None): + if not instance: + return default + + if scheme == "http": + _warn_scheme_deprecated() + + if instance.startswith("http"): + return instance.rstrip("/") + else: + return f"{scheme}://{instance}" + + +def _warn_scheme_deprecated(): + from toot.output import print_err + + print_err("\n".join([ + "--disable-https flag is deprecated and will be removed.", + "Please specify the instance as URL instead.", + "e.g. instead of writing:", + " toot instance unsafehost.com --disable-https", + "instead write:", + " toot instance http://unsafehost.com\n" + ])) From ae882d4958045c6b50fc04d64ed6712709fb197b Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Tue, 7 Mar 2023 10:40:18 +0100 Subject: [PATCH 2/2] Fix tests --- tests/test_api.py | 2 +- tests/test_auth.py | 8 +++++--- tests/test_integration.py | 26 ++++++++++++-------------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/tests/test_api.py b/tests/test_api.py index 65f815a..3b5c5b1 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -14,7 +14,7 @@ def test_create_app(mock_post): 'client_secret': 'bar', }) - create_app('bigfish.software') + create_app('https://bigfish.software') mock_post.assert_called_once_with('https://bigfish.software/api/v1/apps', json={ 'website': CLIENT_WEBSITE, diff --git a/tests/test_auth.py b/tests/test_auth.py index e8e3301..a3267cc 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -13,17 +13,18 @@ def test_register_app(monkeypatch): assert app.client_secret == "cs" monkeypatch.setattr(api, 'create_app', retval(app_data)) - monkeypatch.setattr(api, 'get_instance', retval({"title": "foo", "version": "1"})) + monkeypatch.setattr(api, 'get_instance', retval({"title": "foo", "version": "1", "uri": "bezdomni.net"})) monkeypatch.setattr(config, 'save_app', assert_app) - app = auth.register_app("foo.bar") + app = auth.register_app("foo.bar", "https://foo.bar") assert_app(app) def test_create_app_from_config(monkeypatch): """When there is saved config, it's returned""" monkeypatch.setattr(config, 'load_app', retval("loaded app")) - app = auth.create_app_interactive("bezdomni.net") + monkeypatch.setattr(api, 'get_instance', retval({"title": "foo", "version": "1", "uri": "bezdomni.net"})) + app = auth.create_app_interactive("https://bezdomni.net") assert app == 'loaded app' @@ -31,6 +32,7 @@ def test_create_app_registered(monkeypatch): """When there is no saved config, a new app is registered""" monkeypatch.setattr(config, 'load_app', retval(None)) monkeypatch.setattr(auth, 'register_app', retval("registered app")) + monkeypatch.setattr(api, 'get_instance', retval({"title": "foo", "version": "1", "uri": "bezdomni.net"})) app = auth.create_app_interactive("bezdomni.net") assert app == 'registered app' diff --git a/tests/test_integration.py b/tests/test_integration.py index ba254cc..1939fee 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -30,7 +30,7 @@ from unittest import mock # Host name of a test instance to run integration tests against # DO NOT USE PUBLIC INSTANCES!!! -HOSTNAME = os.getenv("TOOT_TEST_HOSTNAME") +BASE_URL = os.getenv("TOOT_TEST_BASE_URL") # Mastodon database name, used to confirm user registration without having to click the link DATABASE_DSN = os.getenv("TOOT_TEST_DATABASE_DSN") @@ -39,7 +39,7 @@ DATABASE_DSN = os.getenv("TOOT_TEST_DATABASE_DSN") TRUMPET = path.join(path.dirname(path.dirname(path.realpath(__file__))), "trumpet.png") -if not HOSTNAME or not DATABASE_DSN: +if not BASE_URL or not DATABASE_DSN: pytest.skip("Skipping integration tests", allow_module_level=True) # ------------------------------------------------------------------------------ @@ -48,8 +48,9 @@ if not HOSTNAME or not DATABASE_DSN: def create_app(): - response = api.create_app(HOSTNAME, scheme="http") - return App(HOSTNAME, f"http://{HOSTNAME}", response["client_id"], response["client_secret"]) + instance = api.get_instance(BASE_URL) + response = api.create_app(BASE_URL) + return App(instance["uri"], BASE_URL, response["client_id"], response["client_secret"]) def register_account(app: App): @@ -115,7 +116,7 @@ def test_instance(app, run): def test_instance_anon(app, run_anon): - out = run_anon("instance", "--disable-https", HOSTNAME) + out = run_anon("instance", BASE_URL) assert "Mastodon" in out assert app.instance in out assert "running Mastodon" in out @@ -123,7 +124,7 @@ def test_instance_anon(app, run_anon): # Need to specify the instance name when running anon with pytest.raises(ConsoleError) as exc: run_anon("instance") - assert str(exc.value) == "Please specify instance name." + assert str(exc.value) == "Please specify an instance." def test_post(app, user, run): @@ -411,7 +412,6 @@ def test_whoami(user, run): out = run("whoami") # TODO: test other fields once updating account is supported assert f"@{user.username}" in out - assert f"http://{HOSTNAME}/@{user.username}" in out def test_whois(app, friend, run): @@ -425,7 +425,6 @@ def test_whois(app, friend, run): for username in variants: out = run("whois", username) assert f"@{friend.username}" in out - assert f"http://{HOSTNAME}/@{friend.username}" in out def test_search_account(friend, run): @@ -514,22 +513,22 @@ def test_tags(run): assert out == "✓ You are now following #foo" out = run("tags_followed") - assert out == "* #foo\thttp://localhost:3000/tags/foo" + assert out == f"* #foo\t{BASE_URL}/tags/foo" out = run("tags_follow", "bar") assert out == "✓ You are now following #bar" out = run("tags_followed") assert out == "\n".join([ - "* #bar\thttp://localhost:3000/tags/bar", - "* #foo\thttp://localhost:3000/tags/foo", + f"* #bar\t{BASE_URL}/tags/bar", + f"* #foo\t{BASE_URL}/tags/foo", ]) out = run("tags_unfollow", "foo") assert out == "✓ You are no longer following #foo" out = run("tags_followed") - assert out == "* #bar\thttp://localhost:3000/tags/bar" + assert out == f"* #bar\t{BASE_URL}/tags/bar" def test_update_account_no_options(run): @@ -667,7 +666,6 @@ def _posted_status_id(out): match = re.search(pattern, out) assert match - host, _, status_id = match.groups() - assert host == HOSTNAME + _, _, status_id = match.groups() return status_id