From 9ecfa79db8abaf626cf16527cb54bfb1f36f7192 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Sun, 26 Nov 2023 18:00:57 +0100 Subject: [PATCH 01/59] Setup click, migrate read commands --- setup.py | 3 +- tests/integration/conftest.py | 38 +++---- tests/integration/test_read.py | 178 ++++++++++++++++++++++----------- toot/__main__.py | 16 ++- toot/cli/__init__.py | 4 + toot/cli/base.py | 67 +++++++++++++ toot/cli/read.py | 112 +++++++++++++++++++++ toot/cli/tags.py | 33 ++++++ toot/exceptions.py | 7 +- 9 files changed, 376 insertions(+), 82 deletions(-) create mode 100644 toot/cli/__init__.py create mode 100644 toot/cli/base.py create mode 100644 toot/cli/read.py create mode 100644 toot/cli/tags.py diff --git a/setup.py b/setup.py index e11ad5c..aa56055 100644 --- a/setup.py +++ b/setup.py @@ -34,6 +34,7 @@ setup( packages=['toot', 'toot.tui', 'toot.tui.richtext', 'toot.utils'], python_requires=">=3.7", install_requires=[ + "click~=8.1", "requests>=2.13,<3.0", "beautifulsoup4>=4.5.0,<5.0", "wcwidth>=0.1.7", @@ -62,7 +63,7 @@ setup( }, entry_points={ 'console_scripts': [ - 'toot=toot.console:main', + 'toot=toot.cli:cli', ], } ) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 8fcd1cb..dc387ea 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -20,8 +20,10 @@ import psycopg2 import pytest import uuid +from click.testing import CliRunner, Result from pathlib import Path from toot import api, App, User +from toot.cli import Context from toot.console import run_command from toot.exceptions import ApiError, ConsoleError from toot.output import print_out @@ -105,19 +107,21 @@ def friend_id(app, user, friend): return api.find_account(app, user, friend.username)["id"] -@pytest.fixture -def run(app, user, capsys): - def _run(command, *params, as_user=None): - # The try/catch duplicates logic from console.main to convert exceptions - # to printed error messages. TODO: could be deduped - try: - run_command(app, as_user or user, command, params or []) - except (ConsoleError, ApiError) as e: - print_out(str(e)) +@pytest.fixture(scope="session", autouse=True) +def testing_env(): + os.environ["TOOT_TESTING"] = "true" - out, err = capsys.readouterr() - assert err == "" - return strip_ansi(out) + +@pytest.fixture(scope="session") +def runner(): + return CliRunner(mix_stderr=False) + + +@pytest.fixture +def run(app, user, runner): + def _run(command, *params, as_user=None) -> Result: + ctx = Context(app, as_user or user) + return runner.invoke(command, params, obj=ctx) return _run @@ -130,12 +134,10 @@ def run_json(run): @pytest.fixture -def run_anon(capsys): - def _run(command, *params): - run_command(None, None, command, params or []) - out, err = capsys.readouterr() - assert err == "" - return strip_ansi(out) +def run_anon(runner): + def _run(command, *params) -> Result: + ctx = Context(None, None) + return runner.invoke(command, params, obj=ctx) return _run diff --git a/tests/integration/test_read.py b/tests/integration/test_read.py index 67e7783..a9bb914 100644 --- a/tests/integration/test_read.py +++ b/tests/integration/test_read.py @@ -1,45 +1,58 @@ import json -from pprint import pprint -import pytest import re -from toot import api -from toot.entities import Account, from_dict_list -from toot.exceptions import ConsoleError +from toot import api, cli +from toot.entities import Account, Status, from_dict, from_dict_list from uuid import uuid4 def test_instance(app, run): - out = run("instance", "--disable-https") - assert "Mastodon" in out - assert app.instance in out - assert "running Mastodon" in out + result = run(cli.instance) + assert result.exit_code == 0 + + assert "Mastodon" in result.stdout + assert app.instance in result.stdout + assert "running Mastodon" in result.stdout def test_instance_json(app, run): - out = run("instance", "--json") - data = json.loads(out) + result = run(cli.instance, "--json") + assert result.exit_code == 0 + + data = json.loads(result.stdout) assert data["title"] is not None assert data["description"] is not None assert data["version"] is not None def test_instance_anon(app, run_anon, base_url): - out = run_anon("instance", base_url) - assert "Mastodon" in out - assert app.instance in out - assert "running Mastodon" in out + result = run_anon(cli.instance, base_url) + assert result.exit_code == 0 + + assert "Mastodon" in result.stdout + assert app.instance in result.stdout + assert "running Mastodon" in result.stdout # Need to specify the instance name when running anon - with pytest.raises(ConsoleError) as exc: - run_anon("instance") - assert str(exc.value) == "Please specify an instance." + result = run_anon(cli.instance) + assert result.exit_code == 1 + assert result.stderr == "Error: Please specify an instance.\n" def test_whoami(user, run): - out = run("whoami") - # TODO: test other fields once updating account is supported - assert f"@{user.username}" in out + result = run(cli.whoami) + assert result.exit_code == 0 + assert f"@{user.username}" in result.stdout + + +def test_whoami_json(user, run): + result = run(cli.whoami, "--json") + assert result.exit_code == 0 + + data = json.loads(result.stdout) + account = from_dict(Account, data) + assert account.username == user.username + assert account.acct == user.username def test_whois(app, friend, run): @@ -51,18 +64,33 @@ def test_whois(app, friend, run): ] for username in variants: - out = run("whois", username) - assert f"@{friend.username}" in out + result = run(cli.whois, username) + assert result.exit_code == 0 + assert f"@{friend.username}" in result.stdout + + +def test_whois_json(app, friend, run): + result = run(cli.whois, friend.username, "--json") + assert result.exit_code == 0 + + data = json.loads(result.stdout) + account = from_dict(Account, data) + assert account.username == friend.username + assert account.acct == friend.username def test_search_account(friend, run): - out = run("search", friend.username) - assert out == f"Accounts:\n* @{friend.username}" + result = run(cli.search, friend.username) + assert result.exit_code == 0 + assert result.stdout.strip() == f"Accounts:\n* @{friend.username}" -def test_search_account_json(friend, run_json): - out = run_json("search", friend.username, "--json") - [account] = from_dict_list(Account, out["accounts"]) +def test_search_account_json(friend, run): + result = run(cli.search, friend.username, "--json") + assert result.exit_code == 0 + + data = json.loads(result.stdout) + [account] = from_dict_list(Account, data["accounts"]) assert account.acct == friend.username @@ -71,17 +99,21 @@ def test_search_hashtag(app, user, run): api.post_status(app, user, "#hashtag_y") api.post_status(app, user, "#hashtag_z") - out = run("search", "#hashtag") - assert out == "Hashtags:\n#hashtag_x, #hashtag_y, #hashtag_z" + result = run(cli.search, "#hashtag") + assert result.exit_code == 0 + assert result.stdout.strip() == "Hashtags:\n#hashtag_x, #hashtag_y, #hashtag_z" -def test_search_hashtag_json(app, user, run_json): +def test_search_hashtag_json(app, user, run): api.post_status(app, user, "#hashtag_x") api.post_status(app, user, "#hashtag_y") api.post_status(app, user, "#hashtag_z") - out = run_json("search", "#hashtag", "--json") - [h1, h2, h3] = sorted(out["hashtags"], key=lambda h: h["name"]) + result = run(cli.search, "#hashtag", "--json") + assert result.exit_code == 0 + + data = json.loads(result.stdout) + [h1, h2, h3] = sorted(data["hashtags"], key=lambda h: h["name"]) assert h1["name"] == "hashtag_x" assert h2["name"] == "hashtag_y" @@ -89,50 +121,78 @@ def test_search_hashtag_json(app, user, run_json): def test_tags(run, base_url): - out = run("tags_followed") - assert out == "You're not following any hashtags." + result = run(cli.tags_followed) + assert result.exit_code == 0 + assert result.stdout.strip() == "You're not following any hashtags." - out = run("tags_follow", "foo") - assert out == "✓ You are now following #foo" + result = run(cli.tags_follow, "foo") + assert result.exit_code == 0 + assert result.stdout.strip() == "✓ You are now following #foo" - out = run("tags_followed") - assert out == f"* #foo\t{base_url}/tags/foo" + result = run(cli.tags_followed) + assert result.exit_code == 0 + assert result.stdout.strip() == f"* #foo\t{base_url}/tags/foo" - out = run("tags_follow", "bar") - assert out == "✓ You are now following #bar" + result = run(cli.tags_follow, "bar") + assert result.exit_code == 0 + assert result.stdout.strip() == "✓ You are now following #bar" - out = run("tags_followed") - assert out == "\n".join([ + result = run(cli.tags_followed) + assert result.exit_code == 0 + assert result.stdout.strip() == "\n".join([ 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" + result = run(cli.tags_unfollow, "foo") + assert result.exit_code == 0 + assert result.stdout.strip() == "✓ You are no longer following #foo" - out = run("tags_followed") - assert out == f"* #bar\t{base_url}/tags/bar" + result = run(cli.tags_followed) + assert result.exit_code == 0 + assert result.stdout.strip() == f"* #bar\t{base_url}/tags/bar" def test_status(app, user, run): uuid = str(uuid4()) - response = api.post_status(app, user, uuid).json() + status_id = api.post_status(app, user, uuid).json()["id"] - out = run("status", response["id"]) + result = run(cli.status, status_id) + assert result.exit_code == 0 + + out = result.stdout.strip() assert uuid in out assert user.username in out - assert response["id"] in out + assert status_id in out + + +def test_status_json(app, user, run): + uuid = str(uuid4()) + status_id = api.post_status(app, user, uuid).json()["id"] + + result = run(cli.status, status_id, "--json") + assert result.exit_code == 0 + + status = from_dict(Status, json.loads(result.stdout)) + assert status.id == status_id + assert status.account.acct == user.username + assert uuid in status.content def test_thread(app, user, run): - uuid = str(uuid4()) - s1 = api.post_status(app, user, uuid + "1").json() - s2 = api.post_status(app, user, uuid + "2", in_reply_to_id=s1["id"]).json() - s3 = api.post_status(app, user, uuid + "3", in_reply_to_id=s2["id"]).json() + uuid1 = str(uuid4()) + uuid2 = str(uuid4()) + uuid3 = str(uuid4()) + + s1 = api.post_status(app, user, uuid1).json() + s2 = api.post_status(app, user, uuid2, in_reply_to_id=s1["id"]).json() + s3 = api.post_status(app, user, uuid3, in_reply_to_id=s2["id"]).json() for status in [s1, s2, s3]: - out = run("thread", status["id"]) - bits = re.split(r"─+", out) + result = run(cli.thread, status["id"]) + assert result.exit_code == 0 + + bits = re.split(r"─+", result.stdout.strip()) bits = [b for b in bits if b] assert len(bits) == 3 @@ -141,6 +201,6 @@ def test_thread(app, user, run): assert s2["id"] in bits[1] assert s3["id"] in bits[2] - assert f"{uuid}1" in bits[0] - assert f"{uuid}2" in bits[1] - assert f"{uuid}3" in bits[2] + assert uuid1 in bits[0] + assert uuid2 in bits[1] + assert uuid3 in bits[2] diff --git a/toot/__main__.py b/toot/__main__.py index abbb9e2..403038e 100644 --- a/toot/__main__.py +++ b/toot/__main__.py @@ -1,3 +1,15 @@ -from .console import main +import sys +from toot.cli import cli +from toot.exceptions import ConsoleError +from toot.output import print_err +from toot.settings import load_settings -main() +try: + defaults = load_settings().get("commands", {}) + cli(default_map=defaults) +except ConsoleError as ex: + print_err(str(ex)) + sys.exit(1) +except KeyboardInterrupt: + print_err("Aborted") + sys.exit(1) diff --git a/toot/cli/__init__.py b/toot/cli/__init__.py new file mode 100644 index 0000000..16a7cb4 --- /dev/null +++ b/toot/cli/__init__.py @@ -0,0 +1,4 @@ +from toot.cli.base import cli, Context # noqa + +from toot.cli.read import * +from toot.cli.tags import * diff --git a/toot/cli/base.py b/toot/cli/base.py new file mode 100644 index 0000000..fb37cb9 --- /dev/null +++ b/toot/cli/base.py @@ -0,0 +1,67 @@ +import logging +import sys +import click + +from functools import wraps +from toot import App, User, config +from typing import Callable, Concatenate, NamedTuple, Optional, ParamSpec, TypeVar + + +# Tweak the Click context +# https://click.palletsprojects.com/en/8.1.x/api/#context +CONTEXT = dict( + # Enable using environment variables to set options + auto_envvar_prefix="TOOT", + # Add shorthand -h for invoking help + help_option_names=["-h", "--help"], + # Give help some more room (default is 80) + max_content_width=100, + # Always show default values for options + show_default=True, +) + + +# Data object to add to Click context +class Context(NamedTuple): + app: Optional[App] = None + user: Optional[User] = None + color: bool = False + debug: bool = False + quiet: bool = False + + +P = ParamSpec("P") +R = TypeVar("R") +T = TypeVar("T") + + +def pass_context(f: Callable[Concatenate[Context, P], R]) -> Callable[P, R]: + """Pass `obj` from click context as first argument.""" + @wraps(f) + def wrapped(*args: P.args, **kwargs: P.kwargs) -> R: + ctx = click.get_current_context() + return f(ctx.obj, *args, **kwargs) + + return wrapped + + +json_option = click.option( + "--json", + is_flag=True, + default=False, + help="Print data as JSON rather than human readable text" +) + + +@click.group(context_settings=CONTEXT) +@click.option("--debug/--no-debug", default=False, help="Log debug info to stderr") +@click.option("--color/--no-color", default=sys.stdout.isatty(), help="Use ANSI color in output") +@click.option("--quiet/--no-quiet", default=False, help="Don't print anything to stdout") +@click.pass_context +def cli(ctx, color, debug, quiet, app=None, user=None): + """Toot is a Mastodon CLI""" + user, app = config.get_active_user_app() + ctx.obj = Context(app, user, color, debug, quiet) + + if debug: + logging.basicConfig(level=logging.DEBUG) diff --git a/toot/cli/read.py b/toot/cli/read.py new file mode 100644 index 0000000..f83d449 --- /dev/null +++ b/toot/cli/read.py @@ -0,0 +1,112 @@ +import click +import json as pyjson + +from itertools import chain +from typing import Optional + +from toot import api +from toot.entities import Instance, Status, from_dict, Account +from toot.exceptions import ApiError, ConsoleError +from toot.output import print_account, print_instance, print_search_results, print_status, print_tag_list, print_timeline +from toot.cli.base import cli, json_option, pass_context, Context + + +@cli.command() +@json_option +@pass_context +def whoami(ctx: Context, json: bool): + """Display logged in user details""" + response = api.verify_credentials(ctx.app, ctx.user) + + if json: + click.echo(response.text) + else: + account = from_dict(Account, response.json()) + print_account(account) + + +@cli.command() +@click.argument("account") +@json_option +@pass_context +def whois(ctx: Context, account: str, json: bool): + """Display account details""" + account_dict = api.find_account(ctx.app, ctx.user, account) + + # Here it's not possible to avoid parsing json since it's needed to find the account. + if json: + click.echo(pyjson.dumps(account_dict)) + else: + account_obj = from_dict(Account, account_dict) + print_account(account_obj) + + +@cli.command() +@click.argument("instance_url", required=False) +@json_option +@pass_context +def instance(ctx: Context, instance_url: Optional[str], json: bool): + """Display instance details""" + default_url = ctx.app.base_url if ctx.app else None + base_url = instance_url or default_url + + if not base_url: + raise ConsoleError("Please specify an instance.") + + try: + response = api.get_instance(base_url) + except ApiError: + raise ConsoleError( + f"Instance not found at {base_url}.\n" + + "The given domain probably does not host a Mastodon instance." + ) + + if json: + print(response.text) + else: + instance = from_dict(Instance, response.json()) + print_instance(instance) + + +@cli.command() +@click.argument("query") +@click.option("-r", "--resolve", is_flag=True, help="Resolve non-local accounts") +@json_option +@pass_context +def search(ctx: Context, query: str, resolve: bool, json: bool): + response = api.search(ctx.app, ctx.user, query, resolve) + if json: + print(response.text) + else: + print_search_results(response.json()) + + +@cli.command() +@click.argument("status_id") +@json_option +@pass_context +def status(ctx: Context, status_id: str, json: bool): + """Show a single status""" + response = api.fetch_status(ctx.app, ctx.user, status_id) + if json: + print(response.text) + else: + status = from_dict(Status, response.json()) + print_status(status) + + +@cli.command() +@click.argument("status_id") +@json_option +@pass_context +def thread(ctx: Context, status_id: str, json: bool): + """Show thread for a toot.""" + context_response = api.context(ctx.app, ctx.user, status_id) + if json: + print(context_response.text) + else: + toot = api.fetch_status(ctx.app, ctx.user, status_id).json() + context = context_response.json() + + statuses = chain(context["ancestors"], [toot], context["descendants"]) + print_timeline(from_dict(Status, s) for s in statuses) diff --git a/toot/cli/tags.py b/toot/cli/tags.py new file mode 100644 index 0000000..c13d613 --- /dev/null +++ b/toot/cli/tags.py @@ -0,0 +1,33 @@ +import click + +from toot import api +from toot.cli.base import cli, pass_context, Context +from toot.output import print_tag_list + + +@cli.command(name="tags_followed") +@pass_context +def tags_followed(ctx: Context): + """List hashtags you follow""" + response = api.followed_tags(ctx.app, ctx.user) + print_tag_list(response) + + +@cli.command(name="tags_follow") +@click.argument("tag") +@pass_context +def tags_follow(ctx: Context, tag: str): + """Follow a hashtag""" + tag = tag.lstrip("#") + api.follow_tag(ctx.app, ctx.user, tag) + click.secho(f"✓ You are now following #{tag}", fg="green") + + +@cli.command(name="tags_unfollow") +@click.argument("tag") +@pass_context +def tags_unfollow(ctx: Context, tag: str): + """Unfollow a hashtag""" + tag = tag.lstrip("#") + api.unfollow_tag(ctx.app, ctx.user, tag) + click.secho(f"✓ You are no longer following #{tag}", fg="green") diff --git a/toot/exceptions.py b/toot/exceptions.py index 2bf495d..c5e2350 100644 --- a/toot/exceptions.py +++ b/toot/exceptions.py @@ -1,4 +1,7 @@ -class ApiError(Exception): +from click import ClickException + + +class ApiError(ClickException): """Raised when an API request fails for whatever reason.""" @@ -10,5 +13,5 @@ class AuthenticationError(ApiError): """Raised when login fails.""" -class ConsoleError(Exception): +class ConsoleError(ClickException): """Raised when an error occurs which needs to be show to the user.""" From 096ec096847f5536f0580e09b6ee6d6b4095fd55 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Tue, 28 Nov 2023 10:13:20 +0100 Subject: [PATCH 02/59] Add toot --version --- toot/cli/base.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/toot/cli/base.py b/toot/cli/base.py index fb37cb9..b26f3e3 100644 --- a/toot/cli/base.py +++ b/toot/cli/base.py @@ -3,7 +3,7 @@ import sys import click from functools import wraps -from toot import App, User, config +from toot import App, User, config, __version__ from typing import Callable, Concatenate, NamedTuple, Optional, ParamSpec, TypeVar @@ -57,6 +57,7 @@ json_option = click.option( @click.option("--debug/--no-debug", default=False, help="Log debug info to stderr") @click.option("--color/--no-color", default=sys.stdout.isatty(), help="Use ANSI color in output") @click.option("--quiet/--no-quiet", default=False, help="Don't print anything to stdout") +@click.version_option(__version__, message="%(prog)s v%(version)s") @click.pass_context def cli(ctx, color, debug, quiet, app=None, user=None): """Toot is a Mastodon CLI""" From d6678e049835f6fbe147c2533f15b39af69310e9 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Tue, 28 Nov 2023 11:50:44 +0100 Subject: [PATCH 03/59] Migrate post command --- tests/integration/test_accounts.py | 4 + tests/integration/test_auth.py | 5 + tests/integration/test_lists.py | 4 + tests/integration/test_post.py | 142 +++++++++++----- tests/integration/test_status.py | 3 + tests/test_api.py | 1 + toot/cli/__init__.py | 1 + toot/cli/post.py | 254 +++++++++++++++++++++++++++++ toot/cli/validators.py | 45 +++++ 9 files changed, 419 insertions(+), 40 deletions(-) create mode 100644 toot/cli/post.py create mode 100644 toot/cli/validators.py diff --git a/tests/integration/test_accounts.py b/tests/integration/test_accounts.py index 0555e16..96f8fc3 100644 --- a/tests/integration/test_accounts.py +++ b/tests/integration/test_accounts.py @@ -1,9 +1,13 @@ import json +import pytest from toot import App, User, api from toot.entities import Account, Relationship, from_dict +pytest.skip("TODO", allow_module_level=True) + + def test_whoami(user: User, run): out = run("whoami") # TODO: test other fields once updating account is supported diff --git a/tests/integration/test_auth.py b/tests/integration/test_auth.py index 6720f8b..8a5c06f 100644 --- a/tests/integration/test_auth.py +++ b/tests/integration/test_auth.py @@ -1,9 +1,14 @@ +import pytest + from tests.integration.conftest import TRUMPET from toot import api from toot.entities import Account, from_dict from toot.utils import get_text +pytest.skip("TODO", allow_module_level=True) + + def test_update_account_no_options(run): out = run("update_account") assert out == "Please specify at least one option to update the account" diff --git a/tests/integration/test_lists.py b/tests/integration/test_lists.py index 6f98998..740eebe 100644 --- a/tests/integration/test_lists.py +++ b/tests/integration/test_lists.py @@ -1,5 +1,9 @@ +import pytest + from tests.integration.conftest import register_account +pytest.skip("TODO", allow_module_level=True) + def test_lists_empty(run): out = run("lists") diff --git a/tests/integration/test_post.py b/tests/integration/test_post.py index d3f0e05..bf3f8f4 100644 --- a/tests/integration/test_post.py +++ b/tests/integration/test_post.py @@ -5,15 +5,17 @@ import uuid from datetime import datetime, timedelta, timezone from os import path from tests.integration.conftest import ASSETS_DIR, posted_status_id -from toot import CLIENT_NAME, CLIENT_WEBSITE, api +from toot import CLIENT_NAME, CLIENT_WEBSITE, api, cli from toot.utils import get_text from unittest import mock def test_post(app, user, run): text = "i wish i was a #lumberjack" - out = run("post", text) - status_id = posted_status_id(out) + result = run(cli.post, text) + assert result.exit_code == 0 + + status_id = posted_status_id(result.stdout) status = api.fetch_status(app, user, status_id).json() assert text == get_text(status["content"]) @@ -28,11 +30,18 @@ def test_post(app, user, run): assert status["application"]["website"] == CLIENT_WEBSITE +def test_post_no_text(run): + result = run(cli.post) + assert result.exit_code == 1 + assert result.stderr.strip() == "Error: You must specify either text or media to post." + + def test_post_json(run): content = "i wish i was a #lumberjack" - out = run("post", content, "--json") - status = json.loads(out) + result = run(cli.post, content, "--json") + assert result.exit_code == 0 + status = json.loads(result.stdout) assert get_text(status["content"]) == content assert status["visibility"] == "public" assert status["sensitive"] is False @@ -42,8 +51,10 @@ def test_post_json(run): def test_post_visibility(app, user, run): for visibility in ["public", "unlisted", "private", "direct"]: - out = run("post", "foo", "--visibility", visibility) - status_id = posted_status_id(out) + result = run(cli.post, "foo", "--visibility", visibility) + assert result.exit_code == 0 + + status_id = posted_status_id(result.stdout) status = api.fetch_status(app, user, status_id).json() assert status["visibility"] == visibility @@ -52,14 +63,23 @@ def test_post_scheduled_at(app, user, run): text = str(uuid.uuid4()) scheduled_at = datetime.now(timezone.utc).replace(microsecond=0) + timedelta(minutes=10) - out = run("post", text, "--scheduled-at", scheduled_at.isoformat()) - assert "Toot scheduled for" in out + result = run(cli.post, text, "--scheduled-at", scheduled_at.isoformat()) + assert result.exit_code == 0 + + assert "Toot scheduled for" in result.stdout statuses = api.scheduled_statuses(app, user) [status] = [s for s in statuses if s["params"]["text"] == text] assert datetime.strptime(status["scheduled_at"], "%Y-%m-%dT%H:%M:%S.%f%z") == scheduled_at +def test_post_scheduled_at_error(run): + result = run(cli.post, "foo", "--scheduled-at", "banana") + assert result.exit_code == 1 + # Stupid error returned by mastodon + assert result.stderr.strip() == "Error: Record invalid" + + def test_post_scheduled_in(app, user, run): text = str(uuid.uuid4()) @@ -76,9 +96,11 @@ def test_post_scheduled_in(app, user, run): datetimes = [] for scheduled_in, delta in variants: - out = run("post", text, "--scheduled-in", scheduled_in) + result = run(cli.post, text, "--scheduled-in", scheduled_in) + assert result.exit_code == 0 + dttm = datetime.utcnow() + delta - assert out.startswith(f"Toot scheduled for: {str(dttm)[:16]}") + assert result.stdout.startswith(f"Toot scheduled for: {str(dttm)[:16]}") datetimes.append(dttm) scheduled = api.scheduled_statuses(app, user) @@ -92,18 +114,31 @@ def test_post_scheduled_in(app, user, run): assert delta.total_seconds() < 5 +def test_post_scheduled_in_invalid_duration(run): + result = run(cli.post, "foo", "--scheduled-in", "banana") + assert result.exit_code == 2 + assert "Invalid duration: banana" in result.stderr + + +def test_post_scheduled_in_empty_duration(run): + result = run(cli.post, "foo", "--scheduled-in", "0m") + assert result.exit_code == 2 + assert "Empty duration" in result.stderr + + def test_post_poll(app, user, run): text = str(uuid.uuid4()) - out = run( - "post", text, + result = run( + cli.post, text, "--poll-option", "foo", "--poll-option", "bar", "--poll-option", "baz", "--poll-option", "qux", ) - status_id = posted_status_id(out) + assert result.exit_code == 0 + status_id = posted_status_id(result.stdout) status = api.fetch_status(app, user, status_id).json() assert status["poll"]["expired"] is False @@ -125,15 +160,15 @@ def test_post_poll(app, user, run): def test_post_poll_multiple(app, user, run): text = str(uuid.uuid4()) - out = run( - "post", text, + result = run( + cli.post, text, "--poll-option", "foo", "--poll-option", "bar", "--poll-multiple" ) + assert result.exit_code == 0 - status_id = posted_status_id(out) - + status_id = posted_status_id(result.stdout) status = api.fetch_status(app, user, status_id).json() assert status["poll"]["multiple"] is True @@ -141,14 +176,15 @@ def test_post_poll_multiple(app, user, run): def test_post_poll_expires_in(app, user, run): text = str(uuid.uuid4()) - out = run( - "post", text, + result = run( + cli.post, text, "--poll-option", "foo", "--poll-option", "bar", "--poll-expires-in", "8h", ) + assert result.exit_code == 0 - status_id = posted_status_id(out) + status_id = posted_status_id(result.stdout) status = api.fetch_status(app, user, status_id).json() actual = datetime.strptime(status["poll"]["expires_at"], "%Y-%m-%dT%H:%M:%S.%f%z") @@ -160,14 +196,15 @@ def test_post_poll_expires_in(app, user, run): def test_post_poll_hide_totals(app, user, run): text = str(uuid.uuid4()) - out = run( - "post", text, + result = run( + cli.post, text, "--poll-option", "foo", "--poll-option", "bar", "--poll-hide-totals" ) + assert result.exit_code == 0 - status_id = posted_status_id(out) + status_id = posted_status_id(result.stdout) status = api.fetch_status(app, user, status_id).json() @@ -179,30 +216,41 @@ def test_post_poll_hide_totals(app, user, run): def test_post_language(app, user, run): - out = run("post", "test", "--language", "hr") - status_id = posted_status_id(out) + result = run(cli.post, "test", "--language", "hr") + assert result.exit_code == 0 + + status_id = posted_status_id(result.stdout) status = api.fetch_status(app, user, status_id).json() assert status["language"] == "hr" - out = run("post", "test", "--language", "zh") - status_id = posted_status_id(out) + result = run(cli.post, "test", "--language", "zh") + assert result.exit_code == 0 + + status_id = posted_status_id(result.stdout) status = api.fetch_status(app, user, status_id).json() assert status["language"] == "zh" +def test_post_language_error(run): + result = run(cli.post, "test", "--language", "banana") + assert result.exit_code == 2 + assert "Language should be a two letter abbreviation." in result.stderr + + def test_media_thumbnail(app, user, run): video_path = path.join(ASSETS_DIR, "small.webm") thumbnail_path = path.join(ASSETS_DIR, "test1.png") - out = run( - "post", + result = run( + cli.post, "--media", video_path, "--thumbnail", thumbnail_path, "--description", "foo", "some text" ) + assert result.exit_code == 0 - status_id = posted_status_id(out) + status_id = posted_status_id(result.stdout) status = api.fetch_status(app, user, status_id).json() [media] = status["media_attachments"] @@ -227,8 +275,8 @@ def test_media_attachments(app, user, run): path3 = path.join(ASSETS_DIR, "test3.png") path4 = path.join(ASSETS_DIR, "test4.png") - out = run( - "post", + result = run( + cli.post, "--media", path1, "--media", path2, "--media", path3, @@ -239,8 +287,9 @@ def test_media_attachments(app, user, run): "--description", "Test 4", "some text" ) + assert result.exit_code == 0 - status_id = posted_status_id(out) + status_id = posted_status_id(result.stdout) status = api.fetch_status(app, user, status_id).json() [a1, a2, a3, a4] = status["media_attachments"] @@ -258,6 +307,13 @@ def test_media_attachments(app, user, run): assert a4["description"] == "Test 4" +def test_too_many_media(run): + m = path.join(ASSETS_DIR, "test1.png") + result = run(cli.post, "-m", m, "-m", m, "-m", m, "-m", m, "-m", m) + assert result.exit_code == 1 + assert result.stderr.strip() == "Error: Cannot attach more than 4 files." + + @mock.patch("toot.utils.multiline_input") @mock.patch("sys.stdin.read") def test_media_attachment_without_text(mock_read, mock_ml, app, user, run): @@ -267,8 +323,10 @@ def test_media_attachment_without_text(mock_read, mock_ml, app, user, run): media_path = path.join(ASSETS_DIR, "test1.png") - out = run("post", "--media", media_path) - status_id = posted_status_id(out) + result = run(cli.post, "--media", media_path) + assert result.exit_code == 0 + + status_id = posted_status_id(result.stdout) status = api.fetch_status(app, user, status_id).json() assert status["content"] == "" @@ -284,14 +342,18 @@ def test_media_attachment_without_text(mock_read, mock_ml, app, user, run): def test_reply_thread(app, user, friend, run): status = api.post_status(app, friend, "This is the status").json() - out = run("post", "--reply-to", status["id"], "This is the reply") - status_id = posted_status_id(out) + result = run(cli.post, "--reply-to", status["id"], "This is the reply") + assert result.exit_code == 0 + + status_id = posted_status_id(result.stdout) reply = api.fetch_status(app, user, status_id).json() assert reply["in_reply_to_id"] == status["id"] - out = run("thread", status["id"]) - [s1, s2] = [s.strip() for s in re.split(r"─+", out) if s.strip()] + result = run(cli.thread, status["id"]) + assert result.exit_code == 0 + + [s1, s2] = [s.strip() for s in re.split(r"─+", result.stdout) if s.strip()] assert "This is the status" in s1 assert "This is the reply" in s2 diff --git a/tests/integration/test_status.py b/tests/integration/test_status.py index 3daf65e..f4b7f07 100644 --- a/tests/integration/test_status.py +++ b/tests/integration/test_status.py @@ -6,6 +6,9 @@ from toot import api from toot.exceptions import NotFoundError +pytest.skip("TODO", allow_module_level=True) + + def test_delete(app, user, run): status = api.post_status(app, user, "foo").json() diff --git a/tests/test_api.py b/tests/test_api.py index 3b5c5b1..788a862 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -50,6 +50,7 @@ def test_login(mock_post): 'https://bigfish.software/oauth/token', data=data, allow_redirects=False) +@pytest.mark.skip @mock.patch('toot.http.anon_post') def test_login_failed(mock_post): app = App('bigfish.software', 'https://bigfish.software', 'foo', 'bar') diff --git a/toot/cli/__init__.py b/toot/cli/__init__.py index 16a7cb4..87e6ace 100644 --- a/toot/cli/__init__.py +++ b/toot/cli/__init__.py @@ -1,4 +1,5 @@ from toot.cli.base import cli, Context # noqa +from toot.cli.post import * from toot.cli.read import * from toot.cli.tags import * diff --git a/toot/cli/post.py b/toot/cli/post.py new file mode 100644 index 0000000..fa93a19 --- /dev/null +++ b/toot/cli/post.py @@ -0,0 +1,254 @@ +import sys +from time import sleep, time +import click +import os + +from datetime import datetime, timedelta, timezone +from typing import Optional, Tuple + +from toot import api +from toot.cli.base import cli, json_option, pass_context, Context +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 + + +@cli.command() +@click.argument("text", required=False) +@click.option( + "--media", "-m", + help="""Path to media file to attach, can be used multiple times to attach + multiple files.""", + type=click.File(mode="rb"), + multiple=True +) +@click.option( + "--description", "-d", + help="""Plain-text description of the media for accessibility purposes, one + per attached media""", + multiple=True, +) +@click.option( + "--thumbnail", + help="Path to an image file to serve as media thumbnail, one per attached media", + type=click.File(mode="rb"), + multiple=True +) +@click.option( + "--visibility", "-v", + help="Post visibility", + type=click.Choice(VISIBILITY_CHOICES), + default=get_default_visibility(), +) +@click.option( + "--sensitive", "-s", + help="Mark status and attached media as sensitive", + default=False, + is_flag=True, +) +@click.option( + "--spoiler-text", "-p", + help="Text to be shown as a warning or subject before the actual content.", +) +@click.option( + "--reply-to", "-r", + help="ID of the status being replied to, if status is a reply.", +) +@click.option( + "--language", "-l", + help="ISO 639-1 language code of the toot, to skip automatic detection.", + callback=validate_language, +) +@click.option( + "--editor", "-e", + is_flag=False, + flag_value=os.getenv("EDITOR"), + help="""Specify an editor to compose your toot. When used without a value + it will use the editor defined in the $EDITOR environment variable.""", +) +@click.option( + "--scheduled-at", + help="""ISO 8601 Datetime at which to schedule a status. Must be at least 5 + minutes in the future.""", +) +@click.option( + "--scheduled-in", + help=f"""Schedule the toot to be posted after a given amount of time, + {DURATION_EXAMPLES}. Must be at least 5 minutes.""", + callback=validate_duration, +) +@click.option( + "--content-type", "-t", + help="MIME type for the status text (not supported on all instances)", +) +@click.option( + "--poll-option", + help="Possible answer to the poll, can be given multiple times.", + multiple=True, +) +@click.option( + "--poll-expires-in", + help=f"Duration that the poll should be open, {DURATION_EXAMPLES}", + callback=validate_duration, + default="24h", +) +@click.option( + "--poll-multiple", + help="Allow multiple answers to be selected.", + is_flag=True, + default=False, +) +@click.option( + "--poll-hide-totals", + help="Hide vote counts until the poll ends.", + is_flag=True, + default=False, +) +@json_option +@pass_context +def post( + ctx: Context, + text: Optional[str], + media: Tuple[str], + description: Tuple[str], + thumbnail: Tuple[str], + visibility: str, + sensitive: bool, + spoiler_text: Optional[str], + reply_to: Optional[str], + language: Optional[str], + editor: Optional[str], + scheduled_at: Optional[str], + scheduled_in: Optional[int], + content_type: Optional[str], + poll_option: Tuple[str], + poll_expires_in: int, + poll_multiple: bool, + poll_hide_totals: bool, + json: bool +): + if editor and not sys.stdin.isatty(): + raise click.ClickException("Cannot run editor if not in tty.") + + if len(media) > 4: + raise click.ClickException("Cannot attach more than 4 files.") + + media_ids = _upload_media(ctx.app, ctx.user, media, description, thumbnail) + status_text = _get_status_text(text, editor, media) + scheduled_at = _get_scheduled_at(scheduled_at, scheduled_in) + + if not status_text and not media_ids: + raise click.ClickException("You must specify either text or media to post.") + + response = api.post_status( + ctx.app, + ctx.user, + status_text, + visibility=visibility, + media_ids=media_ids, + sensitive=sensitive, + spoiler_text=spoiler_text, + in_reply_to_id=reply_to, + language=language, + scheduled_at=scheduled_at, + content_type=content_type, + poll_options=poll_option, + poll_expires_in=poll_expires_in, + poll_multiple=poll_multiple, + poll_hide_totals=poll_hide_totals, + ) + + if json: + click.echo(response.text) + else: + status = response.json() + if "scheduled_at" in status: + scheduled_at = parse_datetime(status["scheduled_at"]) + scheduled_at = datetime.strftime(scheduled_at, "%Y-%m-%d %H:%M:%S%z") + click.echo(f"Toot scheduled for: {scheduled_at}") + else: + click.echo(f"Toot posted: {status['url']}") + + delete_tmp_status_file() + + +def _get_status_text(text, editor, media): + isatty = sys.stdin.isatty() + + if not text and not isatty: + text = sys.stdin.read().rstrip() + + if isatty: + if editor: + text = editor_input(editor, text) + elif not text and not media: + click.echo(f"Write or paste your toot. Press {EOF_KEY} to post it.") + text = multiline_input() + + return text + + +def _get_scheduled_at(scheduled_at, scheduled_in): + if scheduled_at: + return scheduled_at + + if scheduled_in: + scheduled_at = datetime.now(timezone.utc) + timedelta(seconds=scheduled_in) + return scheduled_at.replace(microsecond=0).isoformat() + + return None + + +def _upload_media(app, user, media, description, thumbnail): + # Match media to corresponding description and thumbnail + media = media or [] + descriptions = description or [] + thumbnails = thumbnail or [] + uploaded_media = [] + + for idx, file in enumerate(media): + description = descriptions[idx].strip() if idx < len(descriptions) else None + thumbnail = thumbnails[idx] if idx < len(thumbnails) else None + result = _do_upload(app, user, file, description, thumbnail) + uploaded_media.append(result) + + _wait_until_all_processed(app, user, uploaded_media) + + return [m["id"] for m in uploaded_media] + + +def _do_upload(app, user, file, description, thumbnail): + click.echo(f"Uploading media: {file.name}") + return api.upload_media(app, user, file, description=description, thumbnail=thumbnail) + + +def _wait_until_all_processed(app, user, uploaded_media): + """ + Media is uploaded asynchronously, and cannot be attached until the server + has finished processing it. This function waits for that to happen. + + Once media is processed, it will have the URL populated. + """ + if all(m["url"] for m in uploaded_media): + return + + # Timeout after waiting 1 minute + start_time = time() + timeout = 60 + + click.echo("Waiting for media to finish processing...") + for media in uploaded_media: + _wait_until_processed(app, user, media, start_time, timeout) + + +def _wait_until_processed(app, user, media, start_time, timeout): + if media["url"]: + return + + media = api.get_media(app, user, media["id"]) + while not media["url"]: + sleep(1) + if time() > start_time + timeout: + raise click.ClickException(f"Media not processed by server after {timeout} seconds. Aborting.") + media = api.get_media(app, user, media["id"]) diff --git a/toot/cli/validators.py b/toot/cli/validators.py new file mode 100644 index 0000000..5d52d20 --- /dev/null +++ b/toot/cli/validators.py @@ -0,0 +1,45 @@ +import click +import re + + +def validate_language(ctx, param, value): + if value is None: + return None + + value = value.strip().lower() + if re.match(r"^[a-z]{2}$", value): + return value + + raise click.BadParameter("Language should be a two letter abbreviation.") + + +def validate_duration(ctx, param, value: str) -> int: + if value is None: + return None + + match = re.match(r"""^ + (([0-9]+)\s*(days|day|d))?\s* + (([0-9]+)\s*(hours|hour|h))?\s* + (([0-9]+)\s*(minutes|minute|m))?\s* + (([0-9]+)\s*(seconds|second|s))?\s* + $""", value, re.X) + + if not match: + raise click.BadParameter(f"Invalid duration: {value}") + + days = match.group(2) + hours = match.group(5) + minutes = match.group(8) + seconds = match.group(11) + + days = int(match.group(2) or 0) * 60 * 60 * 24 + hours = int(match.group(5) or 0) * 60 * 60 + minutes = int(match.group(8) or 0) * 60 + seconds = int(match.group(11) or 0) + + duration = days + hours + minutes + seconds + + if duration == 0: + raise click.BadParameter("Empty duration") + + return duration From 51fcd60eb591033778d1a35e91a4569cc229e560 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Tue, 28 Nov 2023 12:26:08 +0100 Subject: [PATCH 04/59] Migrate status commands --- tests/integration/test_read.py | 20 +++++ tests/integration/test_status.py | 112 ++++++++++++++--------- toot/cli/__init__.py | 1 + toot/cli/post.py | 1 + toot/cli/statuses.py | 148 +++++++++++++++++++++++++++++++ 5 files changed, 238 insertions(+), 44 deletions(-) create mode 100644 toot/cli/statuses.py diff --git a/tests/integration/test_read.py b/tests/integration/test_read.py index a9bb914..b612170 100644 --- a/tests/integration/test_read.py +++ b/tests/integration/test_read.py @@ -204,3 +204,23 @@ def test_thread(app, user, run): assert uuid1 in bits[0] assert uuid2 in bits[1] assert uuid3 in bits[2] + + +def test_thread_json(app, user, run): + uuid1 = str(uuid4()) + uuid2 = str(uuid4()) + uuid3 = str(uuid4()) + + s1 = api.post_status(app, user, uuid1).json() + s2 = api.post_status(app, user, uuid2, in_reply_to_id=s1["id"]).json() + s3 = api.post_status(app, user, uuid3, in_reply_to_id=s2["id"]).json() + + result = run(cli.thread, s2["id"], "--json") + assert result.exit_code == 0 + + result = json.loads(result.stdout) + [ancestor] = [from_dict(Status, s) for s in result["ancestors"]] + [descendent] = [from_dict(Status, s) for s in result["descendants"]] + + assert ancestor.id == s1["id"] + assert descendent.id == s3["id"] diff --git a/tests/integration/test_status.py b/tests/integration/test_status.py index f4b7f07..1e88ab0 100644 --- a/tests/integration/test_status.py +++ b/tests/integration/test_status.py @@ -2,18 +2,16 @@ import json import time import pytest -from toot import api +from toot import api, cli from toot.exceptions import NotFoundError -pytest.skip("TODO", allow_module_level=True) - - def test_delete(app, user, run): status = api.post_status(app, user, "foo").json() - out = run("delete", status["id"]) - assert out == "✓ Status deleted" + result = run(cli.delete, status["id"]) + assert result.exit_code == 0 + assert result.stdout.strip() == "✓ Status deleted" with pytest.raises(NotFoundError): api.fetch_status(app, user, status["id"]) @@ -22,7 +20,10 @@ def test_delete(app, user, run): def test_delete_json(app, user, run): status = api.post_status(app, user, "foo").json() - out = run("delete", status["id"], "--json") + result = run(cli.delete, status["id"], "--json") + assert result.exit_code == 0 + + out = result.stdout result = json.loads(out) assert result["id"] == status["id"] @@ -34,17 +35,19 @@ def test_favourite(app, user, run): status = api.post_status(app, user, "foo").json() assert not status["favourited"] - out = run("favourite", status["id"]) - assert out == "✓ Status favourited" + result = run(cli.favourite, status["id"]) + assert result.exit_code == 0 + assert result.stdout.strip() == "✓ Status favourited" status = api.fetch_status(app, user, status["id"]).json() assert status["favourited"] - out = run("unfavourite", status["id"]) - assert out == "✓ Status unfavourited" + result = run(cli.unfavourite, status["id"]) + assert result.exit_code == 0 + assert result.stdout.strip() == "✓ Status unfavourited" # A short delay is required before the server returns new data - time.sleep(0.1) + time.sleep(0.2) status = api.fetch_status(app, user, status["id"]).json() assert not status["favourited"] @@ -54,15 +57,17 @@ def test_favourite_json(app, user, run): status = api.post_status(app, user, "foo").json() assert not status["favourited"] - out = run("favourite", status["id"], "--json") - result = json.loads(out) + result = run(cli.favourite, status["id"], "--json") + assert result.exit_code == 0 + result = json.loads(result.stdout) assert result["id"] == status["id"] assert result["favourited"] is True - out = run("unfavourite", status["id"], "--json") - result = json.loads(out) + result = run(cli.unfavourite, status["id"], "--json") + assert result.exit_code == 0 + result = json.loads(result.stdout) assert result["id"] == status["id"] assert result["favourited"] is False @@ -71,17 +76,24 @@ def test_reblog(app, user, run): status = api.post_status(app, user, "foo").json() assert not status["reblogged"] - out = run("reblog", status["id"]) - assert out == "✓ Status reblogged" + result = run(cli.reblogged_by, status["id"]) + assert result.exit_code == 0 + assert result.stdout.strip() == "This status is not reblogged by anyone" + + result = run(cli.reblog, status["id"]) + assert result.exit_code == 0 + assert result.stdout.strip() == "✓ Status reblogged" status = api.fetch_status(app, user, status["id"]).json() assert status["reblogged"] - out = run("reblogged_by", status["id"]) - assert user.username in out + result = run(cli.reblogged_by, status["id"]) + assert result.exit_code == 0 + assert user.username in result.stdout - out = run("unreblog", status["id"]) - assert out == "✓ Status unreblogged" + result = run(cli.unreblog, status["id"]) + assert result.exit_code == 0 + assert result.stdout.strip() == "✓ Status unreblogged" status = api.fetch_status(app, user, status["id"]).json() assert not status["reblogged"] @@ -91,19 +103,23 @@ def test_reblog_json(app, user, run): status = api.post_status(app, user, "foo").json() assert not status["reblogged"] - out = run("reblog", status["id"], "--json") - result = json.loads(out) + result = run(cli.reblog, status["id"], "--json") + assert result.exit_code == 0 + result = json.loads(result.stdout) assert result["reblogged"] is True assert result["reblog"]["id"] == status["id"] - out = run("reblogged_by", status["id"], "--json") - [reblog] = json.loads(out) + result = run(cli.reblogged_by, status["id"], "--json") + assert result.exit_code == 0 + + [reblog] = json.loads(result.stdout) assert reblog["acct"] == user.username - out = run("unreblog", status["id"], "--json") - result = json.loads(out) + result = run(cli.unreblog, status["id"], "--json") + assert result.exit_code == 0 + result = json.loads(result.stdout) assert result["reblogged"] is False assert result["reblog"] is None @@ -112,14 +128,16 @@ def test_pin(app, user, run): status = api.post_status(app, user, "foo").json() assert not status["pinned"] - out = run("pin", status["id"]) - assert out == "✓ Status pinned" + result = run(cli.pin, status["id"]) + assert result.exit_code == 0 + assert result.stdout.strip() == "✓ Status pinned" status = api.fetch_status(app, user, status["id"]).json() assert status["pinned"] - out = run("unpin", status["id"]) - assert out == "✓ Status unpinned" + result = run(cli.unpin, status["id"]) + assert result.exit_code == 0 + assert result.stdout.strip() == "✓ Status unpinned" status = api.fetch_status(app, user, status["id"]).json() assert not status["pinned"] @@ -129,15 +147,17 @@ def test_pin_json(app, user, run): status = api.post_status(app, user, "foo").json() assert not status["pinned"] - out = run("pin", status["id"], "--json") - result = json.loads(out) + result = run(cli.pin, status["id"], "--json") + assert result.exit_code == 0 + result = json.loads(result.stdout) assert result["pinned"] is True assert result["id"] == status["id"] - out = run("unpin", status["id"], "--json") - result = json.loads(out) + result = run(cli.unpin, status["id"], "--json") + assert result.exit_code == 0 + result = json.loads(result.stdout) assert result["pinned"] is False assert result["id"] == status["id"] @@ -146,14 +166,16 @@ def test_bookmark(app, user, run): status = api.post_status(app, user, "foo").json() assert not status["bookmarked"] - out = run("bookmark", status["id"]) - assert out == "✓ Status bookmarked" + result = run(cli.bookmark, status["id"]) + assert result.exit_code == 0 + assert result.stdout.strip() == "✓ Status bookmarked" status = api.fetch_status(app, user, status["id"]).json() assert status["bookmarked"] - out = run("unbookmark", status["id"]) - assert out == "✓ Status unbookmarked" + result = run(cli.unbookmark, status["id"]) + assert result.exit_code == 0 + assert result.stdout.strip() == "✓ Status unbookmarked" status = api.fetch_status(app, user, status["id"]).json() assert not status["bookmarked"] @@ -163,14 +185,16 @@ def test_bookmark_json(app, user, run): status = api.post_status(app, user, "foo").json() assert not status["bookmarked"] - out = run("bookmark", status["id"], "--json") - result = json.loads(out) + result = run(cli.bookmark, status["id"], "--json") + assert result.exit_code == 0 + result = json.loads(result.stdout) assert result["id"] == status["id"] assert result["bookmarked"] is True - out = run("unbookmark", status["id"], "--json") - result = json.loads(out) + result = run(cli.unbookmark, status["id"], "--json") + assert result.exit_code == 0 + result = json.loads(result.stdout) assert result["id"] == status["id"] assert result["bookmarked"] is False diff --git a/toot/cli/__init__.py b/toot/cli/__init__.py index 87e6ace..d45892e 100644 --- a/toot/cli/__init__.py +++ b/toot/cli/__init__.py @@ -2,4 +2,5 @@ from toot.cli.base import cli, Context # noqa from toot.cli.post import * from toot.cli.read import * +from toot.cli.statuses import * from toot.cli.tags import * diff --git a/toot/cli/post.py b/toot/cli/post.py index fa93a19..92b839e 100644 --- a/toot/cli/post.py +++ b/toot/cli/post.py @@ -128,6 +128,7 @@ def post( poll_hide_totals: bool, json: bool ): + """Post a new status""" if editor and not sys.stdin.isatty(): raise click.ClickException("Cannot run editor if not in tty.") diff --git a/toot/cli/statuses.py b/toot/cli/statuses.py new file mode 100644 index 0000000..d675439 --- /dev/null +++ b/toot/cli/statuses.py @@ -0,0 +1,148 @@ +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.output import print_table + + +@cli.command() +@click.argument("status_id") +@json_option +@pass_context +def delete(ctx: Context, status_id: str, json: bool): + """Delete a status""" + response = api.delete_status(ctx.app, ctx.user, status_id) + if json: + click.echo(response.text) + else: + click.secho("✓ Status deleted", fg="green") + + +@cli.command() +@click.argument("status_id") +@json_option +@pass_context +def favourite(ctx: Context, status_id: str, json: bool): + """Favourite a status""" + response = api.favourite(ctx.app, ctx.user, status_id) + if json: + click.echo(response.text) + else: + click.secho("✓ Status favourited", fg="green") + + +@cli.command() +@click.argument("status_id") +@json_option +@pass_context +def unfavourite(ctx: Context, status_id: str, json: bool): + """Unfavourite a status""" + response = api.unfavourite(ctx.app, ctx.user, status_id) + if json: + click.echo(response.text) + else: + click.secho("✓ Status unfavourited", fg="green") + + +@cli.command() +@click.argument("status_id") +@click.option( + "--visibility", "-v", + help="Post visibility", + type=click.Choice(VISIBILITY_CHOICES), + default=get_default_visibility(), +) +@json_option +@pass_context +def reblog(ctx: Context, status_id: str, visibility: str, json: bool): + """Reblog (boost) a status""" + response = api.reblog(ctx.app, ctx.user, status_id, visibility=visibility) + if json: + click.echo(response.text) + else: + click.secho("✓ Status reblogged", fg="green") + + +@cli.command() +@click.argument("status_id") +@json_option +@pass_context +def unreblog(ctx: Context, status_id: str, json: bool): + """Unreblog (unboost) a status""" + response = api.unreblog(ctx.app, ctx.user, status_id) + if json: + click.echo(response.text) + else: + click.secho("✓ Status unreblogged", fg="green") + + +@cli.command() +@click.argument("status_id") +@json_option +@pass_context +def pin(ctx: Context, status_id: str, json: bool): + """Pin a status""" + response = api.pin(ctx.app, ctx.user, status_id) + if json: + click.echo(response.text) + else: + click.secho("✓ Status pinned", fg="green") + + +@cli.command() +@click.argument("status_id") +@json_option +@pass_context +def unpin(ctx: Context, status_id: str, json: bool): + """Unpin a status""" + response = api.unpin(ctx.app, ctx.user, status_id) + if json: + click.echo(response.text) + else: + click.secho("✓ Status unpinned", fg="green") + + +@cli.command() +@click.argument("status_id") +@json_option +@pass_context +def bookmark(ctx: Context, status_id: str, json: bool): + """Bookmark a status""" + response = api.bookmark(ctx.app, ctx.user, status_id) + if json: + click.echo(response.text) + else: + click.secho("✓ Status bookmarked", fg="green") + + +@cli.command() +@click.argument("status_id") +@json_option +@pass_context +def unbookmark(ctx: Context, status_id: str, json: bool): + """Unbookmark a status""" + response = api.unbookmark(ctx.app, ctx.user, status_id) + if json: + click.echo(response.text) + else: + click.secho("✓ Status unbookmarked", fg="green") + + +@cli.command(name="reblogged_by") +@click.argument("status_id") +@json_option +@pass_context +def reblogged_by(ctx: Context, status_id: str, json: bool): + """Show accounts that reblogged a status""" + response = api.reblogged_by(ctx.app, ctx.user, status_id) + + if json: + click.echo(response.text) + else: + rows = [[a["acct"], a["display_name"]] for a in response.json()] + if rows: + headers = ["Account", "Display name"] + print_table(headers, rows) + else: + click.echo("This status is not reblogged by anyone") From 3dc5d35751c552b61ca7667e5dd9dca0aeed3ddd Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Tue, 28 Nov 2023 14:05:44 +0100 Subject: [PATCH 05/59] Migrate account commands --- tests/integration/conftest.py | 8 +- tests/integration/test_accounts.py | 180 +++++++++++++++++++---------- toot/cli/__init__.py | 1 + toot/cli/accounts.py | 159 +++++++++++++++++++++++++ 4 files changed, 281 insertions(+), 67 deletions(-) create mode 100644 toot/cli/accounts.py diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index dc387ea..2ccdf28 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -126,10 +126,12 @@ def run(app, user, runner): @pytest.fixture -def run_json(run): +def run_json(app, user, runner): def _run_json(command, *params): - out = run(command, *params) - return json.loads(out) + ctx = Context(app, user) + result = runner.invoke(command, params, obj=ctx) + assert result.exit_code == 0 + return json.loads(result.stdout) return _run_json diff --git a/tests/integration/test_accounts.py b/tests/integration/test_accounts.py index 96f8fc3..f13f855 100644 --- a/tests/integration/test_accounts.py +++ b/tests/integration/test_accounts.py @@ -1,22 +1,23 @@ import json -import pytest -from toot import App, User, api +from toot import App, User, api, cli from toot.entities import Account, Relationship, from_dict -pytest.skip("TODO", allow_module_level=True) - - def test_whoami(user: User, run): - out = run("whoami") + result = run(cli.whoami) + assert result.exit_code == 0 + # TODO: test other fields once updating account is supported + out = result.stdout.strip() assert f"@{user.username}" in out def test_whoami_json(user: User, run): - out = run("whoami", "--json") - account = from_dict(Account, json.loads(out)) + result = run(cli.whoami, "--json") + assert result.exit_code == 0 + + account = from_dict(Account, json.loads(result.stdout)) assert account.username == user.username @@ -29,83 +30,95 @@ def test_whois(app: App, friend: User, run): ] for username in variants: - out = run("whois", username) - assert f"@{friend.username}" in out + result = run(cli.whois, username) + assert result.exit_code == 0 + assert f"@{friend.username}" in result.stdout def test_following(app: App, user: User, friend: User, friend_id, run): # Make sure we're not initally following friend api.unfollow(app, user, friend_id) - out = run("following", user.username) - assert out == "" + result = run(cli.following, user.username) + assert result.exit_code == 0 + assert result.stdout.strip() == "" - out = run("follow", friend.username) - assert out == f"✓ You are now following {friend.username}" + result = run(cli.follow, friend.username) + assert result.exit_code == 0 + assert result.stdout.strip() == f"✓ You are now following {friend.username}" - out = run("following", user.username) - assert friend.username in out + result = run(cli.following, user.username) + assert result.exit_code == 0 + assert friend.username in result.stdout.strip() # If no account is given defaults to logged in user - out = run("following") - assert friend.username in out + result = run(cli.following) + assert result.exit_code == 0 + assert friend.username in result.stdout.strip() - out = run("unfollow", friend.username) - assert out == f"✓ You are no longer following {friend.username}" + result = run(cli.unfollow, friend.username) + assert result.exit_code == 0 + assert result.stdout.strip() == f"✓ You are no longer following {friend.username}" - out = run("following", user.username) - assert out == "" + result = run(cli.following, user.username) + assert result.exit_code == 0 + assert result.stdout.strip() == "" def test_following_case_insensitive(user: User, friend: User, run): assert friend.username != friend.username.upper() - out = run("follow", friend.username.upper()) + result = run(cli.follow, friend.username.upper()) + assert result.exit_code == 0 + + out = result.stdout.strip() assert out == f"✓ You are now following {friend.username.upper()}" def test_following_not_found(run): - out = run("follow", "bananaman") - assert out == "Account not found" + result = run(cli.follow, "bananaman") + assert result.exit_code == 1 + assert result.stderr.strip() == "Error: Account not found" - out = run("unfollow", "bananaman") - assert out == "Account not found" + result = run(cli.unfollow, "bananaman") + assert result.exit_code == 1 + assert result.stderr.strip() == "Error: Account not found" def test_following_json(app: App, user: User, friend: User, user_id, friend_id, run_json): # Make sure we're not initally following friend api.unfollow(app, user, friend_id) - result = run_json("following", user.username, "--json") + result = run_json(cli.following, user.username, "--json") assert result == [] - result = run_json("followers", friend.username, "--json") + result = run_json(cli.followers, friend.username, "--json") assert result == [] - result = run_json("follow", friend.username, "--json") + result = run_json(cli.follow, friend.username, "--json") relationship = from_dict(Relationship, result) assert relationship.id == friend_id assert relationship.following is True - [result] = run_json("following", user.username, "--json") + [result] = run_json(cli.following, user.username, "--json") relationship = from_dict(Relationship, result) assert relationship.id == friend_id # If no account is given defaults to logged in user - [result] = run_json("following", user.username, "--json") + [result] = run_json(cli.following, user.username, "--json") relationship = from_dict(Relationship, result) assert relationship.id == friend_id - [result] = run_json("followers", friend.username, "--json") + [result] = run_json(cli.followers, friend.username, "--json") assert result["id"] == user_id - result = run_json("unfollow", friend.username, "--json") + result = run_json(cli.unfollow, friend.username, "--json") assert result["id"] == friend_id assert result["following"] is False - result = run_json("following", user.username, "--json") + result = run_json(cli.following, user.username, "--json") assert result == [] - result = run_json("followers", friend.username, "--json") + result = run_json(cli.followers, friend.username, "--json") assert result == [] @@ -113,57 +126,77 @@ def test_mute(app, user, friend, friend_id, run): # Make sure we're not initially muting friend api.unmute(app, user, friend_id) - out = run("muted") + result = run(cli.muted) + assert result.exit_code == 0 + + out = result.stdout.strip() assert out == "No accounts muted" - out = run("mute", friend.username) + result = run(cli.mute, friend.username) + assert result.exit_code == 0 + + out = result.stdout.strip() assert out == f"✓ You have muted {friend.username}" - out = run("muted") + result = run(cli.muted) + assert result.exit_code == 0 + + out = result.stdout.strip() assert friend.username in out - out = run("unmute", friend.username) + result = run(cli.unmute, friend.username) + assert result.exit_code == 0 + + out = result.stdout.strip() assert out == f"✓ {friend.username} is no longer muted" - out = run("muted") + result = run(cli.muted) + assert result.exit_code == 0 + + out = result.stdout.strip() assert out == "No accounts muted" def test_mute_case_insensitive(friend: User, run): - out = run("mute", friend.username.upper()) + result = run(cli.mute, friend.username.upper()) + assert result.exit_code == 0 + + out = result.stdout.strip() assert out == f"✓ You have muted {friend.username.upper()}" def test_mute_not_found(run): - out = run("mute", "doesnotexistperson") - assert out == f"Account not found" + result = run(cli.mute, "doesnotexistperson") + assert result.exit_code == 1 + assert result.stderr.strip() == "Error: Account not found" - out = run("unmute", "doesnotexistperson") - assert out == f"Account not found" + result = run(cli.unmute, "doesnotexistperson") + assert result.exit_code == 1 + assert result.stderr.strip() == "Error: Account not found" def test_mute_json(app: App, user: User, friend: User, run_json, friend_id): # Make sure we're not initially muting friend api.unmute(app, user, friend_id) - result = run_json("muted", "--json") + result = run_json(cli.muted, "--json") assert result == [] - result = run_json("mute", friend.username, "--json") + result = run_json(cli.mute, friend.username, "--json") relationship = from_dict(Relationship, result) assert relationship.id == friend_id assert relationship.muting is True - [result] = run_json("muted", "--json") + [result] = run_json(cli.muted, "--json") account = from_dict(Account, result) assert account.id == friend_id - result = run_json("unmute", friend.username, "--json") + result = run_json(cli.unmute, friend.username, "--json") relationship = from_dict(Relationship, result) assert relationship.id == friend_id assert relationship.muting is False - result = run_json("muted", "--json") + result = run_json(cli.muted, "--json") assert result == [] @@ -171,52 +204,71 @@ def test_block(app, user, friend, friend_id, run): # Make sure we're not initially blocking friend api.unblock(app, user, friend_id) - out = run("blocked") + result = run(cli.blocked) + assert result.exit_code == 0 + + out = result.stdout.strip() assert out == "No accounts blocked" - out = run("block", friend.username) + result = run(cli.block, friend.username) + assert result.exit_code == 0 + + out = result.stdout.strip() assert out == f"✓ You are now blocking {friend.username}" - out = run("blocked") + result = run(cli.blocked) + assert result.exit_code == 0 + + out = result.stdout.strip() assert friend.username in out - out = run("unblock", friend.username) + result = run(cli.unblock, friend.username) + assert result.exit_code == 0 + + out = result.stdout.strip() assert out == f"✓ {friend.username} is no longer blocked" - out = run("blocked") + result = run(cli.blocked) + assert result.exit_code == 0 + + out = result.stdout.strip() assert out == "No accounts blocked" def test_block_case_insensitive(friend: User, run): - out = run("block", friend.username.upper()) + result = run(cli.block, friend.username.upper()) + assert result.exit_code == 0 + + out = result.stdout.strip() assert out == f"✓ You are now blocking {friend.username.upper()}" def test_block_not_found(run): - out = run("block", "doesnotexistperson") - assert out == f"Account not found" + result = run(cli.block, "doesnotexistperson") + assert result.exit_code == 1 + assert result.stderr.strip() == "Error: Account not found" def test_block_json(app: App, user: User, friend: User, run_json, friend_id): # Make sure we're not initially blocking friend api.unblock(app, user, friend_id) - result = run_json("blocked", "--json") + result = run_json(cli.blocked, "--json") assert result == [] - result = run_json("block", friend.username, "--json") + result = run_json(cli.block, friend.username, "--json") relationship = from_dict(Relationship, result) assert relationship.id == friend_id assert relationship.blocking is True - [result] = run_json("blocked", "--json") + [result] = run_json(cli.blocked, "--json") account = from_dict(Account, result) assert account.id == friend_id - result = run_json("unblock", friend.username, "--json") + result = run_json(cli.unblock, friend.username, "--json") relationship = from_dict(Relationship, result) assert relationship.id == friend_id assert relationship.blocking is False - result = run_json("blocked", "--json") + result = run_json(cli.blocked, "--json") assert result == [] diff --git a/toot/cli/__init__.py b/toot/cli/__init__.py index d45892e..2e9efcf 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.accounts import * from toot.cli.post import * from toot.cli.read import * from toot.cli.statuses import * diff --git a/toot/cli/accounts.py b/toot/cli/accounts.py new file mode 100644 index 0000000..5bed310 --- /dev/null +++ b/toot/cli/accounts.py @@ -0,0 +1,159 @@ +import click +import json as pyjson + +from typing import Optional + +from toot import api +from toot.cli.base import cli, json_option, Context, pass_context +from toot.output import print_acct_list + + +@cli.command() +@click.argument("account") +@json_option +@pass_context +def follow(ctx: Context, account: str, json: bool): + """Follow an account""" + found_account = api.find_account(ctx.app, ctx.user, account) + response = api.follow(ctx.app, ctx.user, found_account["id"]) + if json: + click.echo(response.text) + else: + click.secho(f"✓ You are now following {account}", fg="green") + + +@cli.command() +@click.argument("account") +@json_option +@pass_context +def unfollow(ctx: Context, account: str, json: bool): + """Unfollow an account""" + found_account = api.find_account(ctx.app, ctx.user, account) + response = api.unfollow(ctx.app, ctx.user, found_account["id"]) + if json: + click.echo(response.text) + else: + click.secho(f"✓ You are no longer following {account}", fg="green") + + +@cli.command() +@click.argument("account", required=False) +@json_option +@pass_context +def following(ctx: Context, account: Optional[str], json: bool): + """List accounts followed by an account. + + If no account is given list accounts followed by you. + """ + account = account or ctx.user.username + found_account = api.find_account(ctx.app, ctx.user, account) + accounts = api.following(ctx.app, ctx.user, found_account["id"]) + if json: + click.echo(pyjson.dumps(accounts)) + else: + print_acct_list(accounts) + + +@cli.command() +@click.argument("account", required=False) +@json_option +@pass_context +def followers(ctx: Context, account: Optional[str], json: bool): + """List accounts following an account. + + If no account given list accounts following you.""" + account = account or ctx.user.username + found_account = api.find_account(ctx.app, ctx.user, account) + accounts = api.followers(ctx.app, ctx.user, found_account["id"]) + if json: + click.echo(pyjson.dumps(accounts)) + else: + print_acct_list(accounts) + + +@cli.command() +@click.argument("account") +@json_option +@pass_context +def mute(ctx: Context, account: str, json: bool): + """Mute an account""" + found_account = api.find_account(ctx.app, ctx.user, account) + response = api.mute(ctx.app, ctx.user, found_account["id"]) + if json: + click.echo(response.text) + else: + click.secho(f"✓ You have muted {account}", fg="green") + + +@cli.command() +@click.argument("account") +@json_option +@pass_context +def unmute(ctx: Context, account: str, json: bool): + """Unmute an account""" + found_account = api.find_account(ctx.app, ctx.user, account) + response = api.unmute(ctx.app, ctx.user, found_account["id"]) + if json: + click.echo(response.text) + else: + click.secho(f"✓ {account} is no longer muted", fg="green") + + +@cli.command() +@json_option +@pass_context +def muted(ctx: Context, json: bool): + """List muted accounts""" + response = api.muted(ctx.app, ctx.user) + if json: + click.echo(pyjson.dumps(response)) + else: + if len(response) > 0: + click.echo("Muted accounts:") + print_acct_list(response) + else: + click.echo("No accounts muted") + + +@cli.command() +@click.argument("account") +@json_option +@pass_context +def block(ctx: Context, account: str, json: bool): + """Block an account""" + found_account = api.find_account(ctx.app, ctx.user, account) + response = api.block(ctx.app, ctx.user, found_account["id"]) + if json: + click.echo(response.text) + else: + click.secho(f"✓ You are now blocking {account}", fg="green") + + +@cli.command() +@click.argument("account") +@json_option +@pass_context +def unblock(ctx: Context, account: str, json: bool): + """Unblock an account""" + found_account = api.find_account(ctx.app, ctx.user, account) + response = api.unblock(ctx.app, ctx.user, found_account["id"]) + if json: + click.echo(response.text) + else: + click.secho(f"✓ {account} is no longer blocked", fg="green") + + +@cli.command() +@json_option +@pass_context +def blocked(ctx: Context, json: bool): + """List blocked accounts""" + response = api.blocked(ctx.app, ctx.user) + if json: + click.echo(pyjson.dumps(response)) + else: + if len(response) > 0: + click.echo("Blocked accounts:") + print_acct_list(response) + else: + click.echo("No accounts blocked") From c0eb76751fc4bcd6817bb0b9039d7a9561f6285f Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Tue, 28 Nov 2023 16:56:53 +0100 Subject: [PATCH 06/59] Migrate update_account command --- tests/integration/test_auth.py | 93 +++++++++++++++++------------- toot/cli/accounts.py | 101 ++++++++++++++++++++++++++++++++- 2 files changed, 154 insertions(+), 40 deletions(-) diff --git a/tests/integration/test_auth.py b/tests/integration/test_auth.py index 8a5c06f..83343d7 100644 --- a/tests/integration/test_auth.py +++ b/tests/integration/test_auth.py @@ -1,56 +1,60 @@ -import pytest - +from uuid import uuid4 from tests.integration.conftest import TRUMPET -from toot import api +from toot import api, cli from toot.entities import Account, from_dict from toot.utils import get_text -pytest.skip("TODO", allow_module_level=True) - - def test_update_account_no_options(run): - out = run("update_account") - assert out == "Please specify at least one option to update the account" + result = run(cli.update_account) + assert result.exit_code == 1 + assert result.stderr.strip() == "Error: Please specify at least one option to update the account" def test_update_account_display_name(run, app, user): - out = run("update_account", "--display-name", "elwood") - assert out == "✓ Account updated" + name = str(uuid4())[:10] + + result = run(cli.update_account, "--display-name", name) + assert result.exit_code == 0 + assert result.stdout.strip() == "✓ Account updated" account = api.verify_credentials(app, user).json() - assert account["display_name"] == "elwood" + assert account["display_name"] == name def test_update_account_json(run_json, app, user): - out = run_json("update_account", "--display-name", "elwood", "--json") + name = str(uuid4())[:10] + out = run_json(cli.update_account, "--display-name", name, "--json") account = from_dict(Account, out) assert account.acct == user.username - assert account.display_name == "elwood" + assert account.display_name == name def test_update_account_note(run, app, user): note = ("It's 106 miles to Chicago, we got a full tank of gas, half a pack " "of cigarettes, it's dark... and we're wearing sunglasses.") - out = run("update_account", "--note", note) - assert out == "✓ Account updated" + result = run(cli.update_account, "--note", note) + assert result.exit_code == 0 + assert result.stdout.strip() == "✓ Account updated" account = api.verify_credentials(app, user).json() assert get_text(account["note"]) == note def test_update_account_language(run, app, user): - out = run("update_account", "--language", "hr") - assert out == "✓ Account updated" + result = run(cli.update_account, "--language", "hr") + assert result.exit_code == 0 + assert result.stdout.strip() == "✓ Account updated" account = api.verify_credentials(app, user).json() assert account["source"]["language"] == "hr" def test_update_account_privacy(run, app, user): - out = run("update_account", "--privacy", "private") - assert out == "✓ Account updated" + result = run(cli.update_account, "--privacy", "private") + assert result.exit_code == 0 + assert result.stdout.strip() == "✓ Account updated" account = api.verify_credentials(app, user).json() assert account["source"]["privacy"] == "private" @@ -60,8 +64,9 @@ def test_update_account_avatar(run, app, user): account = api.verify_credentials(app, user).json() old_value = account["avatar"] - out = run("update_account", "--avatar", TRUMPET) - assert out == "✓ Account updated" + result = run(cli.update_account, "--avatar", TRUMPET) + assert result.exit_code == 0 + assert result.stdout.strip() == "✓ Account updated" account = api.verify_credentials(app, user).json() assert account["avatar"] != old_value @@ -71,64 +76,74 @@ def test_update_account_header(run, app, user): account = api.verify_credentials(app, user).json() old_value = account["header"] - out = run("update_account", "--header", TRUMPET) - assert out == "✓ Account updated" + result = run(cli.update_account, "--header", TRUMPET) + assert result.exit_code == 0 + assert result.stdout.strip() == "✓ Account updated" account = api.verify_credentials(app, user).json() assert account["header"] != old_value def test_update_account_locked(run, app, user): - out = run("update_account", "--locked") - assert out == "✓ Account updated" + result = run(cli.update_account, "--locked") + assert result.exit_code == 0 + assert result.stdout.strip() == "✓ Account updated" account = api.verify_credentials(app, user).json() assert account["locked"] is True - out = run("update_account", "--no-locked") - assert out == "✓ Account updated" + result = run(cli.update_account, "--no-locked") + assert result.exit_code == 0 + assert result.stdout.strip() == "✓ Account updated" account = api.verify_credentials(app, user).json() assert account["locked"] is False def test_update_account_bot(run, app, user): - out = run("update_account", "--bot") - assert out == "✓ Account updated" + result = run(cli.update_account, "--bot") + + assert result.exit_code == 0 + assert result.stdout.strip() == "✓ Account updated" account = api.verify_credentials(app, user).json() assert account["bot"] is True - out = run("update_account", "--no-bot") - assert out == "✓ Account updated" + result = run(cli.update_account, "--no-bot") + assert result.exit_code == 0 + assert result.stdout.strip() == "✓ Account updated" account = api.verify_credentials(app, user).json() assert account["bot"] is False def test_update_account_discoverable(run, app, user): - out = run("update_account", "--discoverable") - assert out == "✓ Account updated" + result = run(cli.update_account, "--discoverable") + assert result.exit_code == 0 + assert result.stdout.strip() == "✓ Account updated" account = api.verify_credentials(app, user).json() assert account["discoverable"] is True - out = run("update_account", "--no-discoverable") - assert out == "✓ Account updated" + result = run(cli.update_account, "--no-discoverable") + assert result.exit_code == 0 + assert result.stdout.strip() == "✓ Account updated" account = api.verify_credentials(app, user).json() assert account["discoverable"] is False def test_update_account_sensitive(run, app, user): - out = run("update_account", "--sensitive") - assert out == "✓ Account updated" + result = run(cli.update_account, "--sensitive") + assert result.exit_code == 0 + assert result.stdout.strip() == "✓ Account updated" account = api.verify_credentials(app, user).json() assert account["source"]["sensitive"] is True - out = run("update_account", "--no-sensitive") - assert out == "✓ Account updated" + result = run(cli.update_account, "--no-sensitive") + assert result.exit_code == 0 + assert result.stdout.strip() == "✓ Account updated" account = api.verify_credentials(app, user).json() assert account["source"]["sensitive"] is False diff --git a/toot/cli/accounts.py b/toot/cli/accounts.py index 5bed310..a8c63c1 100644 --- a/toot/cli/accounts.py +++ b/toot/cli/accounts.py @@ -1,13 +1,112 @@ import click import json as pyjson -from typing import Optional +from typing import BinaryIO, Optional from toot import api from toot.cli.base import 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 +@cli.command(name="update_account") +@click.option("--display-name", help="The display name to use for the profile.") +@click.option("--note", help="The account bio.") +@click.option( + "--avatar", + type=click.File(mode="rb"), + help="Path to the avatar image to set.", +) +@click.option( + "--header", + type=click.File(mode="rb"), + help="Path to the header image to set.", +) +@click.option( + "--bot/--no-bot", + default=None, + help="Whether the account has a bot flag.", +) +@click.option( + "--discoverable/--no-discoverable", + default=None, + help="Whether the account should be shown in the profile directory.", +) +@click.option( + "--locked/--no-locked", + default=None, + help="Whether manual approval of follow requests is required.", +) +@click.option( + "--privacy", + type=click.Choice(PRIVACY_CHOICES), + help="Default post privacy for authored statuses.", +) +@click.option( + "--sensitive/--no-sensitive", + default=None, + help="Whether to mark authored statuses as sensitive by default.", +) +@click.option( + "--language", + callback=validate_language, + help="Default language to use for authored statuses (ISO 639-1).", +) +@json_option +@pass_context +def update_account( + ctx: Context, + display_name: Optional[str], + note: Optional[str], + avatar: Optional[BinaryIO], + header: Optional[BinaryIO], + bot: Optional[bool], + discoverable: Optional[bool], + locked: Optional[bool], + privacy: Optional[bool], + sensitive: Optional[bool], + language: Optional[bool], + json: bool, +): + """Update your account details""" + options = [ + avatar, + bot, + discoverable, + display_name, + header, + language, + locked, + note, + privacy, + sensitive, + ] + + if all(option is None for option in options): + raise click.ClickException("Please specify at least one option to update the account") + + response = api.update_account( + ctx.app, + ctx.user, + avatar=avatar, + bot=bot, + discoverable=discoverable, + display_name=display_name, + header=header, + language=language, + locked=locked, + note=note, + privacy=privacy, + sensitive=sensitive, + ) + + if json: + click.echo(response.text) + else: + click.secho("✓ Account updated", fg="green") + + @cli.command() @click.argument("account") @json_option From 5d9ee44cec19a58cc98b08acde381c159ded826c Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Wed, 29 Nov 2023 07:21:03 +0100 Subject: [PATCH 07/59] Migrate list commands --- tests/integration/test_lists.py | 97 ++++++++++++++++------------- toot/cli/__init__.py | 1 + toot/cli/lists.py | 104 ++++++++++++++++++++++++++++++++ 3 files changed, 161 insertions(+), 41 deletions(-) create mode 100644 toot/cli/lists.py diff --git a/tests/integration/test_lists.py b/tests/integration/test_lists.py index 740eebe..171176e 100644 --- a/tests/integration/test_lists.py +++ b/tests/integration/test_lists.py @@ -1,71 +1,86 @@ -import pytest +from toot import cli from tests.integration.conftest import register_account -pytest.skip("TODO", allow_module_level=True) - def test_lists_empty(run): - out = run("lists") - assert out == "You have no lists defined." + result = run(cli.lists) + assert result.exit_code == 0 + assert result.stdout.strip() == "You have no lists defined." def test_list_create_delete(run): - out = run("list_create", "banana") - assert out == '✓ List "banana" created.' + result = run(cli.list_create, "banana") + assert result.exit_code == 0 + assert result.stdout.strip() == '✓ List "banana" created.' - out = run("lists") - assert "banana" in out + result = run(cli.lists) + assert result.exit_code == 0 + assert "banana" in result.stdout - out = run("list_create", "mango") - assert out == '✓ List "mango" created.' + result = run(cli.list_create, "mango") + assert result.exit_code == 0 + assert result.stdout.strip() == '✓ List "mango" created.' - out = run("lists") - assert "banana" in out - assert "mango" in out + result = run(cli.lists) + assert result.exit_code == 0 + assert "banana" in result.stdout + assert "mango" in result.stdout - out = run("list_delete", "banana") - assert out == '✓ List "banana" deleted.' + result = run(cli.list_delete, "banana") + assert result.exit_code == 0 + assert result.stdout.strip() == '✓ List "banana" deleted.' - out = run("lists") - assert "banana" not in out - assert "mango" in out + result = run(cli.lists) + assert result.exit_code == 0 + assert "banana" not in result.stdout + assert "mango" in result.stdout - out = run("list_delete", "mango") - assert out == '✓ List "mango" deleted.' + result = run(cli.list_delete, "mango") + assert result.exit_code == 0 + assert result.stdout.strip() == '✓ List "mango" deleted.' - out = run("lists") - assert out == "You have no lists defined." + result = run(cli.lists) + assert result.exit_code == 0 + assert result.stdout.strip() == "You have no lists defined." - out = run("list_delete", "mango") - assert out == "List not found" + result = run(cli.list_delete, "mango") + assert result.exit_code == 1 + assert result.stderr.strip() == "Error: List not found" def test_list_add_remove(run, app): acc = register_account(app) - run("list_create", "foo") + run(cli.list_create, "foo") - out = run("list_add", "foo", acc.username) - assert out == f"You must follow @{acc.username} before adding this account to a list." + result = run(cli.list_add, "foo", acc.username) + assert result.exit_code == 1 + assert result.stderr.strip() == f"Error: You must follow @{acc.username} before adding this account to a list." - run("follow", acc.username) + run(cli.follow, acc.username) - out = run("list_add", "foo", acc.username) - assert out == f'✓ Added account "{acc.username}"' + result = run(cli.list_add, "foo", acc.username) + assert result.exit_code == 0 + assert result.stdout.strip() == f'✓ Added account "{acc.username}"' - out = run("list_accounts", "foo") - assert acc.username in out + result = run(cli.list_accounts, "foo") + assert result.exit_code == 0 + assert acc.username in result.stdout # Account doesn't exist - out = run("list_add", "foo", "does_not_exist") - assert out == "Account not found" + result = run(cli.list_add, "foo", "does_not_exist") + assert result.exit_code == 1 + assert result.stderr.strip() == "Error: Account not found" # List doesn't exist - out = run("list_add", "does_not_exist", acc.username) - assert out == "List not found" + result = run(cli.list_add, "does_not_exist", acc.username) + assert result.exit_code == 1 + assert result.stderr.strip() == "Error: List not found" - out = run("list_remove", "foo", acc.username) - assert out == f'✓ Removed account "{acc.username}"' + result = run(cli.list_remove, "foo", acc.username) + assert result.exit_code == 0 + assert result.stdout.strip() == f'✓ Removed account "{acc.username}"' - out = run("list_accounts", "foo") - assert out == "This list has no accounts." + result = run(cli.list_accounts, "foo") + assert result.exit_code == 0 + assert result.stdout.strip() == "This list has no accounts." diff --git a/toot/cli/__init__.py b/toot/cli/__init__.py index 2e9efcf..c1d4a3b 100644 --- a/toot/cli/__init__.py +++ b/toot/cli/__init__.py @@ -1,6 +1,7 @@ from toot.cli.base import cli, Context # noqa from toot.cli.accounts import * +from toot.cli.lists import * from toot.cli.post import * from toot.cli.read import * from toot.cli.statuses import * diff --git a/toot/cli/lists.py b/toot/cli/lists.py new file mode 100644 index 0000000..089a2fa --- /dev/null +++ b/toot/cli/lists.py @@ -0,0 +1,104 @@ +import click + +from toot import api +from toot.cli.base import Context, cli, pass_context +from toot.output import print_list_accounts, print_lists + + +@cli.command() +@pass_context +def lists(ctx: Context): + """List all lists""" + lists = api.get_lists(ctx.app, ctx.user) + + if lists: + print_lists(lists) + else: + click.echo("You have no lists defined.") + + +@cli.command(name="list_accounts") +@click.argument("title", required=False) +@click.option("--id", help="List ID if not title is given") +@pass_context +def list_accounts(ctx: Context, title: str, id: str): + """List the accounts in a list""" + list_id = _get_list_id(ctx, title, id) + response = api.get_list_accounts(ctx.app, ctx.user, list_id) + print_list_accounts(response) + + +@cli.command(name="list_create") +@click.argument("title") +@click.option( + "--replies-policy", + type=click.Choice(["followed", "list", "none"]), + default="none", + help="Replies policy" +) +@pass_context +def list_create(ctx: Context, title: str, replies_policy: str): + """Create a list""" + api.create_list(ctx.app, ctx.user, title=title, replies_policy=replies_policy) + click.secho(f"✓ List \"{title}\" created.", fg="green") + + +@cli.command(name="list_delete") +@click.argument("title", required=False) +@click.option("--id", help="List ID if not title is given") +@pass_context +def list_delete(ctx: Context, title: str, id: str): + """Delete a list""" + list_id = _get_list_id(ctx, title, id) + api.delete_list(ctx.app, ctx.user, list_id) + click.secho(f"✓ List \"{title if title else id}\" deleted.", fg="green") + + +@cli.command(name="list_add") +@click.argument("title", required=False) +@click.argument("account") +@click.option("--id", help="List ID if not title is given") +@pass_context +def list_add(ctx: Context, title: str, account: str, id: str): + """Add an account to a list""" + list_id = _get_list_id(ctx, title, id) + found_account = api.find_account(ctx.app, ctx.user, account) + + try: + api.add_accounts_to_list(ctx.app, ctx.user, list_id, [found_account["id"]]) + except Exception: + # if we failed to add the account, try to give a + # more specific error message than "record not found" + my_accounts = api.followers(ctx.app, ctx.user, found_account["id"]) + found = False + if my_accounts: + for my_account in my_accounts: + if my_account["id"] == found_account["id"]: + found = True + break + if found is False: + raise click.ClickException(f"You must follow @{account} before adding this account to a list.") + raise + + click.secho(f"✓ Added account \"{account}\"", fg="green") + + +@cli.command(name="list_remove") +@click.argument("title", required=False) +@click.argument("account") +@click.option("--id", help="List ID if not title is given") +@pass_context +def list_remove(ctx: Context, title: str, account: str, id: str): + """Remove an account from a list""" + list_id = _get_list_id(ctx, title, id) + found_account = api.find_account(ctx.app, ctx.user, account) + api.remove_accounts_from_list(ctx.app, ctx.user, list_id, [found_account["id"]]) + click.secho(f"✓ Removed account \"{account}\"", fg="green") + + +def _get_list_id(ctx: Context, title, list_id): + if not list_id: + list_id = api.find_list_id(ctx.app, ctx.user, title) + if not list_id: + raise click.ClickException("List not found") + return list_id From 16e28d02c672bdeb8066077c05b61089b3374b84 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Thu, 30 Nov 2023 11:25:01 +0100 Subject: [PATCH 08/59] Fix getting the instance domain name This used to return 3000 when running locally on localhost:3000 --- toot/auth.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/toot/auth.py b/toot/auth.py index 0311013..b9a0597 100644 --- a/toot/auth.py +++ b/toot/auth.py @@ -50,16 +50,11 @@ def get_instance_domain(base_url): # 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 - parsed_uri = urlparse(instance["uri"]) - - if parsed_uri.netloc: - # Pleroma, Akkoma, GotoSocial, etc. - return parsed_uri.netloc - else: - # Others including Mastodon servers - return parsed_uri.path - + return uri # NB: when updating to v2 instance endpoint, this field has been renamed to `domain` From 6c9b939175eb90eadd2ae82f2af70af450721830 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Thu, 30 Nov 2023 12:12:41 +0100 Subject: [PATCH 09/59] Better test file name --- tests/integration/{test_auth.py => test_update_account.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/integration/{test_auth.py => test_update_account.py} (100%) diff --git a/tests/integration/test_auth.py b/tests/integration/test_update_account.py similarity index 100% rename from tests/integration/test_auth.py rename to tests/integration/test_update_account.py From e5c8fc4f774dba00e3e5599596b08d3cf79b4abe Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Thu, 30 Nov 2023 20:08:59 +0100 Subject: [PATCH 10/59] Extend instance tests --- tests/integration/conftest.py | 14 +++++--------- tests/integration/test_read.py | 12 +++++++++++- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 2ccdf28..d6e08dc 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -24,9 +24,6 @@ from click.testing import CliRunner, Result from pathlib import Path from toot import api, App, User from toot.cli import Context -from toot.console import run_command -from toot.exceptions import ApiError, ConsoleError -from toot.output import print_out def pytest_configure(config): @@ -36,6 +33,7 @@ def pytest_configure(config): # Mastodon database name, used to confirm user registration without having to click the link DATABASE_DSN = os.getenv("TOOT_TEST_DATABASE_DSN") +TOOT_TEST_BASE_URL = os.getenv("TOOT_TEST_BASE_URL") # Toot logo used for testing image upload TRUMPET = str(Path(__file__).parent.parent.parent / "trumpet.png") @@ -74,12 +72,10 @@ def confirm_user(email): # DO NOT USE PUBLIC INSTANCES!!! @pytest.fixture(scope="session") def base_url(): - base_url = os.getenv("TOOT_TEST_BASE_URL") - - if not base_url: + if not TOOT_TEST_BASE_URL: pytest.skip("Skipping integration tests, TOOT_TEST_BASE_URL not set") - return base_url + return TOOT_TEST_BASE_URL @pytest.fixture(scope="session") @@ -119,9 +115,9 @@ def runner(): @pytest.fixture def run(app, user, runner): - def _run(command, *params, as_user=None) -> Result: + def _run(command, *params, as_user=None, input=None) -> Result: ctx = Context(app, as_user or user) - return runner.invoke(command, params, obj=ctx) + return runner.invoke(command, params, obj=ctx, input=input) return _run diff --git a/tests/integration/test_read.py b/tests/integration/test_read.py index b612170..78cd231 100644 --- a/tests/integration/test_read.py +++ b/tests/integration/test_read.py @@ -1,12 +1,13 @@ import json import re +from tests.integration.conftest import TOOT_TEST_BASE_URL from toot import api, cli from toot.entities import Account, Status, from_dict, from_dict_list from uuid import uuid4 -def test_instance(app, run): +def test_instance_default(app, run): result = run(cli.instance) assert result.exit_code == 0 @@ -15,6 +16,15 @@ def test_instance(app, run): assert "running Mastodon" in result.stdout +def test_instance_with_url(app, run): + result = run(cli.instance, TOOT_TEST_BASE_URL) + assert result.exit_code == 0 + + assert "Mastodon" in result.stdout + assert app.instance in result.stdout + assert "running Mastodon" in result.stdout + + def test_instance_json(app, run): result = run(cli.instance, "--json") assert result.exit_code == 0 From 696a9dcc2e93dc0fccaed1a99a18339a77ca98c7 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Thu, 30 Nov 2023 20:10:19 +0100 Subject: [PATCH 11/59] Add type hints for App and User --- toot/__init__.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/toot/__init__.py b/toot/__init__.py index 9889d57..43f19a4 100644 --- a/toot/__init__.py +++ b/toot/__init__.py @@ -2,12 +2,23 @@ import os import sys from os.path import join, expanduser -from collections import namedtuple +from typing import NamedTuple __version__ = '0.39.0' -App = namedtuple('App', ['instance', 'base_url', 'client_id', 'client_secret']) -User = namedtuple('User', ['instance', 'username', 'access_token']) + +class App(NamedTuple): + instance: str + base_url: str + client_id: str + client_secret: str + + +class User(NamedTuple): + instance: str + username: str + access_token: str + DEFAULT_INSTANCE = 'https://mastodon.social' From d8c7084678a5c2018dbf00c2aecb494e2f6c5bf4 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Thu, 30 Nov 2023 20:12:04 +0100 Subject: [PATCH 12/59] Migrate auth commands --- Makefile | 2 +- changelog.yaml | 6 + tests/integration/test_auth.py | 217 +++++++++++++++++++++++++++++++++ tests/test_utils.py | 15 ++- toot/api.py | 14 +-- toot/auth.py | 128 ++++++------------- toot/cli/__init__.py | 1 + toot/cli/accounts.py | 3 +- toot/cli/auth.py | 143 ++++++++++++++++++++++ toot/cli/base.py | 21 +++- toot/cli/post.py | 2 +- toot/cli/statuses.py | 2 +- toot/cli/validators.py | 19 ++- toot/config.py | 3 +- 14 files changed, 459 insertions(+), 117 deletions(-) create mode 100644 tests/integration/test_auth.py create mode 100644 toot/cli/auth.py 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]) From 69a11f35693b5076363f01132fffc772d9cf61c3 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Sat, 2 Dec 2023 11:10:33 +0100 Subject: [PATCH 13/59] Remove old mock tests These will be replaced by simpler and more useful integration tests. --- tests/test_api.py | 73 ------- tests/test_console.py | 449 ------------------------------------------ 2 files changed, 522 deletions(-) delete mode 100644 tests/test_api.py delete mode 100644 tests/test_console.py diff --git a/tests/test_api.py b/tests/test_api.py deleted file mode 100644 index 788a862..0000000 --- a/tests/test_api.py +++ /dev/null @@ -1,73 +0,0 @@ -import pytest - -from unittest import mock - -from toot import App, CLIENT_NAME, CLIENT_WEBSITE -from toot.api import create_app, login, SCOPES, AuthenticationError -from tests.utils import MockResponse - - -@mock.patch('toot.http.anon_post') -def test_create_app(mock_post): - mock_post.return_value = MockResponse({ - 'client_id': 'foo', - 'client_secret': 'bar', - }) - - create_app('https://bigfish.software') - - mock_post.assert_called_once_with('https://bigfish.software/api/v1/apps', json={ - 'website': CLIENT_WEBSITE, - 'client_name': CLIENT_NAME, - 'scopes': SCOPES, - 'redirect_uris': 'urn:ietf:wg:oauth:2.0:oob', - }) - - -@mock.patch('toot.http.anon_post') -def test_login(mock_post): - app = App('bigfish.software', 'https://bigfish.software', 'foo', 'bar') - - data = { - 'grant_type': 'password', - 'client_id': app.client_id, - 'client_secret': app.client_secret, - 'username': 'user', - 'password': 'pass', - 'scope': SCOPES, - } - - mock_post.return_value = MockResponse({ - 'token_type': 'bearer', - 'scope': 'read write follow', - 'access_token': 'xxx', - 'created_at': 1492523699 - }) - - login(app, 'user', 'pass') - - mock_post.assert_called_once_with( - 'https://bigfish.software/oauth/token', data=data, allow_redirects=False) - - -@pytest.mark.skip -@mock.patch('toot.http.anon_post') -def test_login_failed(mock_post): - app = App('bigfish.software', 'https://bigfish.software', 'foo', 'bar') - - data = { - 'grant_type': 'password', - 'client_id': app.client_id, - 'client_secret': app.client_secret, - 'username': 'user', - 'password': 'pass', - 'scope': SCOPES, - } - - mock_post.return_value = MockResponse(is_redirect=True) - - with pytest.raises(AuthenticationError): - login(app, 'user', 'pass') - - mock_post.assert_called_once_with( - 'https://bigfish.software/oauth/token', data=data, allow_redirects=False) diff --git a/tests/test_console.py b/tests/test_console.py deleted file mode 100644 index 028e836..0000000 --- a/tests/test_console.py +++ /dev/null @@ -1,449 +0,0 @@ -import io -import pytest -import re - -from collections import namedtuple -from unittest import mock - -from toot import console, User, App, http -from toot.exceptions import ConsoleError - -from tests.utils import MockResponse - -app = App('habunek.com', 'https://habunek.com', 'foo', 'bar') -user = User('habunek.com', 'ivan@habunek.com', 'xxx') - -MockUuid = namedtuple("MockUuid", ["hex"]) - - -def uncolorize(text): - """Remove ANSI color sequences from a string""" - return re.sub(r'\x1b[^m]*m', '', text) - - -def test_print_usage(capsys): - console.print_usage() - out, err = capsys.readouterr() - assert "toot - a Mastodon CLI client" in out - - -@mock.patch('uuid.uuid4') -@mock.patch('toot.http.post') -def test_post_defaults(mock_post, mock_uuid, capsys): - mock_uuid.return_value = MockUuid("rock-on") - mock_post.return_value = MockResponse({ - 'url': 'https://habunek.com/@ihabunek/1234567890' - }) - - console.run_command(app, user, 'post', ['Hello world']) - - mock_post.assert_called_once_with(app, user, '/api/v1/statuses', json={ - 'status': 'Hello world', - 'visibility': 'public', - 'media_ids': [], - 'sensitive': False, - }, headers={"Idempotency-Key": "rock-on"}) - - out, err = capsys.readouterr() - assert 'Toot posted' in out - assert 'https://habunek.com/@ihabunek/1234567890' in out - assert not err - - -@mock.patch('uuid.uuid4') -@mock.patch('toot.http.post') -def test_post_with_options(mock_post, mock_uuid, capsys): - mock_uuid.return_value = MockUuid("up-the-irons") - args = [ - 'Hello world', - '--visibility', 'unlisted', - '--sensitive', - '--spoiler-text', 'Spoiler!', - '--reply-to', '123a', - '--language', 'hr', - ] - - mock_post.return_value = MockResponse({ - 'url': 'https://habunek.com/@ihabunek/1234567890' - }) - - console.run_command(app, user, 'post', args) - - mock_post.assert_called_once_with(app, user, '/api/v1/statuses', json={ - 'status': 'Hello world', - 'media_ids': [], - 'visibility': 'unlisted', - 'sensitive': True, - 'spoiler_text': "Spoiler!", - 'in_reply_to_id': '123a', - 'language': 'hr', - }, headers={"Idempotency-Key": "up-the-irons"}) - - out, err = capsys.readouterr() - assert 'Toot posted' in out - assert 'https://habunek.com/@ihabunek/1234567890' in out - assert not err - - -def test_post_invalid_visibility(capsys): - args = ['Hello world', '--visibility', 'foo'] - - with pytest.raises(SystemExit): - console.run_command(app, user, 'post', args) - - out, err = capsys.readouterr() - assert "invalid visibility value: 'foo'" in err - - -def test_post_invalid_media(capsys): - args = ['Hello world', '--media', 'does_not_exist.jpg'] - - with pytest.raises(SystemExit): - console.run_command(app, user, 'post', args) - - out, err = capsys.readouterr() - assert "can't open 'does_not_exist.jpg'" in err - - -@mock.patch('toot.http.delete') -def test_delete(mock_delete, capsys): - console.run_command(app, user, 'delete', ['12321']) - - mock_delete.assert_called_once_with(app, user, '/api/v1/statuses/12321') - - out, err = capsys.readouterr() - assert 'Status deleted' in out - assert not err - - -@mock.patch('toot.http.get') -def test_timeline(mock_get, monkeypatch, capsys): - mock_get.return_value = MockResponse([{ - 'id': '111111111111111111', - 'account': { - 'display_name': 'Frank Zappa 🎸', - 'last_status_at': '2017-04-12T15:53:18.174Z', - 'acct': 'fz' - }, - 'created_at': '2017-04-12T15:53:18.174Z', - 'content': "

The computer can't tell you the emotional story. It can give you the exact mathematical design, but what's missing is the eyebrows.

", - 'reblog': None, - 'in_reply_to_id': None, - 'media_attachments': [], - }]) - - console.run_command(app, user, 'timeline', ['--once']) - - mock_get.assert_called_once_with(app, user, '/api/v1/timelines/home', {'limit': 10}) - - out, err = capsys.readouterr() - lines = out.split("\n") - - assert "Frank Zappa 🎸" in lines[1] - assert "@fz" in lines[1] - assert "2017-04-12 15:53 UTC" in lines[1] - - assert ( - "The computer can't tell you the emotional story. It can give you the " - "exact mathematical design, but\nwhat's missing is the eyebrows." in out) - - assert "111111111111111111" in lines[-3] - - assert err == "" - - -@mock.patch('toot.http.get') -def test_timeline_with_re(mock_get, monkeypatch, capsys): - mock_get.return_value = MockResponse([{ - 'id': '111111111111111111', - 'created_at': '2017-04-12T15:53:18.174Z', - 'account': { - 'display_name': 'Frank Zappa', - 'acct': 'fz' - }, - 'reblog': { - 'created_at': '2017-04-12T15:53:18.174Z', - 'account': { - 'display_name': 'Johnny Cash', - 'last_status_at': '2011-04-12', - 'acct': 'jc' - }, - 'content': "

The computer can't tell you the emotional story. It can give you the exact mathematical design, but what's missing is the eyebrows.

", - 'media_attachments': [], - }, - 'in_reply_to_id': '111111111111111110', - 'media_attachments': [], - }]) - - console.run_command(app, user, 'timeline', ['--once']) - - mock_get.assert_called_once_with(app, user, '/api/v1/timelines/home', {'limit': 10}) - - out, err = capsys.readouterr() - lines = uncolorize(out).split("\n") - - assert "Johnny Cash" in lines[1] - assert "@jc" in lines[1] - assert "2017-04-12 15:53 UTC" in lines[1] - - assert ( - "The computer can't tell you the emotional story. It can give you the " - "exact mathematical design, but\nwhat's missing is the eyebrows." in out) - - assert "111111111111111111" in lines[-3] - assert "↻ @fz boosted" in lines[-3] - - assert err == "" - - -@mock.patch('toot.http.post') -def test_upload(mock_post, capsys): - mock_post.return_value = MockResponse({ - 'id': 123, - 'preview_url': 'https://bigfish.software/789/012', - 'url': 'https://bigfish.software/345/678', - 'type': 'image', - }) - - console.run_command(app, user, 'upload', [__file__]) - - assert mock_post.call_count == 1 - - args, kwargs = http.post.call_args - assert args == (app, user, '/api/v2/media') - assert isinstance(kwargs['files']['file'], io.BufferedReader) - - out, err = capsys.readouterr() - assert "Uploading media" in out - assert __file__ in out - - -@mock.patch('toot.http.get') -def test_whoami(mock_get, capsys): - mock_get.return_value = MockResponse({ - 'acct': 'ihabunek', - 'avatar': 'https://files.mastodon.social/accounts/avatars/000/046/103/original/6a1304e135cac514.jpg?1491312434', - 'avatar_static': 'https://files.mastodon.social/accounts/avatars/000/046/103/original/6a1304e135cac514.jpg?1491312434', - 'created_at': '2017-04-04T13:23:09.777Z', - 'display_name': 'Ivan Habunek', - 'followers_count': 5, - 'following_count': 9, - 'header': '/headers/original/missing.png', - 'header_static': '/headers/original/missing.png', - 'id': 46103, - 'locked': False, - 'note': 'A developer.', - 'statuses_count': 19, - 'url': 'https://mastodon.social/@ihabunek', - 'username': 'ihabunek', - 'fields': [] - }) - - console.run_command(app, user, 'whoami', []) - - mock_get.assert_called_once_with(app, user, '/api/v1/accounts/verify_credentials') - - out, err = capsys.readouterr() - out = uncolorize(out) - - assert "@ihabunek Ivan Habunek" in out - assert "A developer." in out - assert "https://mastodon.social/@ihabunek" in out - assert "ID: 46103" in out - assert "Since: 2017-04-04" in out - assert "Followers: 5" in out - assert "Following: 9" in out - assert "Statuses: 19" in out - - -@mock.patch('toot.http.get') -def test_notifications(mock_get, capsys): - mock_get.return_value = MockResponse([{ - 'id': '1', - 'type': 'follow', - 'created_at': '2019-02-16T07:01:20.714Z', - 'account': { - 'display_name': 'Frank Zappa', - 'acct': 'frank@zappa.social', - }, - }, { - 'id': '2', - 'type': 'mention', - 'created_at': '2017-01-12T12:12:12.0Z', - 'account': { - 'display_name': 'Dweezil Zappa', - 'acct': 'dweezil@zappa.social', - }, - 'status': { - 'id': '111111111111111111', - 'account': { - 'display_name': 'Dweezil Zappa', - 'acct': 'dweezil@zappa.social', - }, - 'created_at': '2017-04-12T15:53:18.174Z', - 'content': "

We still have fans in 2017 @fan123

", - 'reblog': None, - 'in_reply_to_id': None, - 'media_attachments': [], - }, - }, { - 'id': '3', - 'type': 'reblog', - 'created_at': '1983-11-03T03:03:03.333Z', - 'account': { - 'display_name': 'Terry Bozzio', - 'acct': 'terry@bozzio.social', - }, - 'status': { - 'id': '1234', - 'account': { - 'display_name': 'Zappa Fan', - 'acct': 'fan123@zappa-fans.social' - }, - 'created_at': '1983-11-04T15:53:18.174Z', - 'content': "

The Black Page, a masterpiece

", - 'reblog': None, - 'in_reply_to_id': None, - 'media_attachments': [], - }, - }, { - 'id': '4', - 'type': 'favourite', - 'created_at': '1983-12-13T01:02:03.444Z', - 'account': { - 'display_name': 'Zappa Old Fan', - 'acct': 'fan9@zappa-fans.social', - }, - 'status': { - 'id': '1234', - 'account': { - 'display_name': 'Zappa Fan', - 'acct': 'fan123@zappa-fans.social' - }, - 'created_at': '1983-11-04T15:53:18.174Z', - 'content': "

The Black Page, a masterpiece

", - 'reblog': None, - 'in_reply_to_id': None, - 'media_attachments': [], - }, - }]) - - console.run_command(app, user, 'notifications', []) - - mock_get.assert_called_once_with(app, user, '/api/v1/notifications', {'exclude_types[]': [], 'limit': 20}) - - out, err = capsys.readouterr() - out = uncolorize(out) - - assert not err - assert out == "\n".join([ - "────────────────────────────────────────────────────────────────────────────────────────────────────", - "Frank Zappa @frank@zappa.social now follows you", - "────────────────────────────────────────────────────────────────────────────────────────────────────", - "Dweezil Zappa @dweezil@zappa.social mentioned you in", - "Dweezil Zappa @dweezil@zappa.social 2017-04-12 15:53 UTC", - "", - "We still have fans in 2017 @fan123", - "", - "ID 111111111111111111 ", - "────────────────────────────────────────────────────────────────────────────────────────────────────", - "Terry Bozzio @terry@bozzio.social reblogged your status", - "Zappa Fan @fan123@zappa-fans.social 1983-11-04 15:53 UTC", - "", - "The Black Page, a masterpiece", - "", - "ID 1234 ", - "────────────────────────────────────────────────────────────────────────────────────────────────────", - "Zappa Old Fan @fan9@zappa-fans.social favourited your status", - "Zappa Fan @fan123@zappa-fans.social 1983-11-04 15:53 UTC", - "", - "The Black Page, a masterpiece", - "", - "ID 1234 ", - "────────────────────────────────────────────────────────────────────────────────────────────────────", - "", - ]) - - -@mock.patch('toot.http.get') -def test_notifications_empty(mock_get, capsys): - mock_get.return_value = MockResponse([]) - - console.run_command(app, user, 'notifications', []) - - mock_get.assert_called_once_with(app, user, '/api/v1/notifications', {'exclude_types[]': [], 'limit': 20}) - - out, err = capsys.readouterr() - out = uncolorize(out) - - assert not err - assert out == "No notification\n" - - -@mock.patch('toot.http.post') -def test_notifications_clear(mock_post, capsys): - console.run_command(app, user, 'notifications', ['--clear']) - out, err = capsys.readouterr() - out = uncolorize(out) - - mock_post.assert_called_once_with(app, user, '/api/v1/notifications/clear') - assert not err - assert out == 'Cleared notifications\n' - - -def u(user_id, access_token="abc"): - username, instance = user_id.split("@") - return { - "instance": instance, - "username": username, - "access_token": access_token, - } - - -@mock.patch('toot.config.save_config') -@mock.patch('toot.config.load_config') -def test_logout(mock_load, mock_save, capsys): - mock_load.return_value = { - "users": { - "king@gizzard.social": u("king@gizzard.social"), - "lizard@wizard.social": u("lizard@wizard.social"), - }, - "active_user": "king@gizzard.social", - } - - console.run_command(app, user, "logout", ["king@gizzard.social"]) - - mock_save.assert_called_once_with({ - 'users': { - 'lizard@wizard.social': u("lizard@wizard.social") - }, - 'active_user': None - }) - - out, err = capsys.readouterr() - assert "✓ User king@gizzard.social logged out" in out - - -@mock.patch('toot.config.save_config') -@mock.patch('toot.config.load_config') -def test_activate(mock_load, mock_save, capsys): - mock_load.return_value = { - "users": { - "king@gizzard.social": u("king@gizzard.social"), - "lizard@wizard.social": u("lizard@wizard.social"), - }, - "active_user": "king@gizzard.social", - } - - console.run_command(app, user, "activate", ["lizard@wizard.social"]) - - mock_save.assert_called_once_with({ - 'users': { - "king@gizzard.social": u("king@gizzard.social"), - 'lizard@wizard.social': u("lizard@wizard.social") - }, - 'active_user': "lizard@wizard.social" - }) - - out, err = capsys.readouterr() - assert "✓ User lizard@wizard.social active" in out From 2429d9f751dd4cf7001d2cd4b52a6970eb7d385e Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Sun, 3 Dec 2023 07:07:18 +0100 Subject: [PATCH 14/59] Migrate timeline commands --- Makefile | 2 +- tests/integration/conftest.py | 12 +- tests/integration/test_timelines.py | 198 ++++++++++++++++++++++++++++ toot/api.py | 70 +++++++--- toot/cli/__init__.py | 6 +- toot/cli/base.py | 2 +- toot/cli/read.py | 1 + toot/cli/timelines.py | 180 +++++++++++++++++++++++++ 8 files changed, 448 insertions(+), 23 deletions(-) create mode 100644 tests/integration/test_timelines.py create mode 100644 toot/cli/timelines.py diff --git a/Makefile b/Makefile index 4b09396..e560506 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ test: coverage: coverage erase coverage run - coverage html --omit toot/tui/* + coverage html --omit "toot/tui/*" coverage report clean : diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index d6e08dc..528be6a 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -115,12 +115,20 @@ def runner(): @pytest.fixture def run(app, user, runner): - def _run(command, *params, as_user=None, input=None) -> Result: - ctx = Context(app, as_user or user) + def _run(command, *params, input=None) -> Result: + ctx = Context(app, user) return runner.invoke(command, params, obj=ctx, input=input) return _run +@pytest.fixture +def run_as(app, runner): + def _run_as(user, command, *params, input=None) -> Result: + ctx = Context(app, user) + return runner.invoke(command, params, obj=ctx, input=input) + return _run_as + + @pytest.fixture def run_json(app, user, runner): def _run_json(command, *params): diff --git a/tests/integration/test_timelines.py b/tests/integration/test_timelines.py new file mode 100644 index 0000000..5f4da1e --- /dev/null +++ b/tests/integration/test_timelines.py @@ -0,0 +1,198 @@ +import pytest + +from time import sleep +from uuid import uuid4 + +from toot import api, cli +from toot.entities import from_dict, Status +from tests.integration.conftest import TOOT_TEST_BASE_URL, register_account + + +# TODO: If fixture is not overriden here, tests fail, not sure why, figure it out +@pytest.fixture(scope="module") +def user(app): + return register_account(app) + + +@pytest.fixture(scope="module") +def other_user(app): + return register_account(app) + + +@pytest.fixture(scope="module") +def friend_user(app, user): + friend = register_account(app) + friend_account = api.find_account(app, user, friend.username) + api.follow(app, user, friend_account["id"]) + return friend + + +@pytest.fixture(scope="module") +def friend_list(app, user, friend_user): + friend_account = api.find_account(app, user, friend_user.username) + list = api.create_list(app, user, str(uuid4())) + api.add_accounts_to_list(app, user, list["id"], account_ids=[friend_account["id"]]) + return list + + +def test_timelines(app, user, other_user, friend_user, friend_list, run): + status1 = _post_status(app, user, "#foo") + status2 = _post_status(app, other_user, "#bar") + status3 = _post_status(app, friend_user, "#foo #bar") + + # Give mastodon time to process things :/ + # Tests fail if this is removed, required delay depends on server speed + sleep(1) + + # Home timeline + result = run(cli.timeline) + assert result.exit_code == 0 + assert status1.id in result.stdout + assert status2.id not in result.stdout + assert status3.id in result.stdout + + # Public timeline + result = run(cli.timeline, "--public") + assert result.exit_code == 0 + assert status1.id in result.stdout + assert status2.id in result.stdout + assert status3.id in result.stdout + + # Anon public timeline + result = run(cli.timeline, "--instance", TOOT_TEST_BASE_URL, "--public") + assert result.exit_code == 0 + assert status1.id in result.stdout + assert status2.id in result.stdout + assert status3.id in result.stdout + + # Tag timeline + result = run(cli.timeline, "--tag", "foo") + assert result.exit_code == 0 + assert status1.id in result.stdout + assert status2.id not in result.stdout + assert status3.id in result.stdout + + result = run(cli.timeline, "--tag", "bar") + assert result.exit_code == 0 + assert status1.id not in result.stdout + assert status2.id in result.stdout + assert status3.id in result.stdout + + # Anon tag timeline + result = run(cli.timeline, "--instance", TOOT_TEST_BASE_URL, "--tag", "foo") + assert result.exit_code == 0 + assert status1.id in result.stdout + assert status2.id not in result.stdout + assert status3.id in result.stdout + + # List timeline (by list name) + result = run(cli.timeline, "--list", friend_list["title"]) + assert result.exit_code == 0 + assert status1.id not in result.stdout + assert status2.id not in result.stdout + assert status3.id in result.stdout + + # List timeline (by list ID) + result = run(cli.timeline, "--list", friend_list["id"]) + assert result.exit_code == 0 + assert status1.id not in result.stdout + assert status2.id not in result.stdout + assert status3.id in result.stdout + + # Account timeline + result = run(cli.timeline, "--account", friend_user.username) + assert result.exit_code == 0 + assert status1.id not in result.stdout + assert status2.id not in result.stdout + assert status3.id in result.stdout + + result = run(cli.timeline, "--account", other_user.username) + assert result.exit_code == 0 + assert status1.id not in result.stdout + assert status2.id in result.stdout + assert status3.id not in result.stdout + + +def test_empty_timeline(app, run_as): + user = register_account(app) + result = run_as(user, cli.timeline) + assert result.exit_code == 0 + assert result.stdout.strip() == "─" * 100 + + +def test_timeline_cant_combine_timelines(run): + result = run(cli.timeline, "--tag", "foo", "--account", "bar") + assert result.exit_code == 1 + assert result.stderr.strip() == "Error: Only one of --public, --tag, --account, or --list can be used at one time." + + +def test_timeline_local_needs_public_or_tag(run): + result = run(cli.timeline, "--local") + assert result.exit_code == 1 + assert result.stderr.strip() == "Error: The --local option is only valid alongside --public or --tag." + + +def test_timeline_instance_needs_public_or_tag(run): + result = run(cli.timeline, "--instance", TOOT_TEST_BASE_URL) + assert result.exit_code == 1 + assert result.stderr.strip() == "Error: The --instance option is only valid alongside --public or --tag." + + +def test_bookmarks(app, user, run): + status1 = _post_status(app, user) + status2 = _post_status(app, user) + + api.bookmark(app, user, status1.id) + api.bookmark(app, user, status2.id) + + result = run(cli.bookmarks) + assert result.exit_code == 0 + assert status1.id in result.stdout + assert status2.id in result.stdout + assert result.stdout.find(status1.id) > result.stdout.find(status2.id) + + + result = run(cli.bookmarks, "--reverse") + assert result.exit_code == 0 + assert status1.id in result.stdout + assert status2.id in result.stdout + assert result.stdout.find(status1.id) < result.stdout.find(status2.id) + + +def test_notifications(app, user, other_user, run): + result = run(cli.notifications) + assert result.exit_code == 0 + assert result.stdout.strip() == "You have no notifications" + + text = f"Paging doctor @{user.username}" + status = _post_status(app, other_user, text) + sleep(0.5) # grr + + result = run(cli.notifications) + assert result.exit_code == 0 + assert f"@{other_user.username} mentioned you" in result.stdout + assert status.id in result.stdout + assert text in result.stdout + + result = run(cli.notifications, "--mentions") + assert result.exit_code == 0 + assert f"@{other_user.username} mentioned you" in result.stdout + assert status.id in result.stdout + assert text in result.stdout + + +def test_notifications_follow(app, user, friend_user, run_as): + result = run_as(friend_user, cli.notifications) + assert result.exit_code == 0 + assert f"@{user.username} now follows you" in result.stdout + + + result = run_as(friend_user, cli.notifications, "--mentions") + assert result.exit_code == 0 + assert "now follows you" not in result.stdout + + +def _post_status(app, user, text=None) -> Status: + text = text or str(uuid4()) + response = api.post_status(app, user, text) + return from_dict(Status, response.json()) diff --git a/toot/api.py b/toot/api.py index b7136d6..34b6bbd 100644 --- a/toot/api.py +++ b/toot/api.py @@ -8,7 +8,7 @@ from typing import BinaryIO, List, Optional from urllib.parse import urlparse, urlencode, quote from toot import App, User, http, CLIENT_NAME, CLIENT_WEBSITE -from toot.exceptions import AuthenticationError, ConsoleError +from toot.exceptions import ConsoleError from toot.utils import drop_empty_values, str_bool, str_bool_nullable @@ -300,6 +300,35 @@ def reblogged_by(app, user, status_id) -> Response: return http.get(app, user, url) +def get_timeline_generator( + app: Optional[App], + user: Optional[User], + base_url: Optional[str] = None, + account: Optional[str] = None, + list_id: Optional[str] = None, + tag: Optional[str] = None, + local: bool = False, + public: bool = False, + limit=20, # TODO +): + if public: + if base_url: + return anon_public_timeline_generator(base_url, local=local, limit=limit) + else: + return public_timeline_generator(app, user, local=local, limit=limit) + elif tag: + if base_url: + return anon_tag_timeline_generator(base_url, tag, limit=limit) + else: + return tag_timeline_generator(app, user, tag, local=local, limit=limit) + elif account: + return account_timeline_generator(app, user, account, limit=limit) + elif list_id: + return timeline_list_generator(app, user, list_id, limit=limit) + else: + return home_timeline_generator(app, user, limit=limit) + + def _get_next_path(headers): """Given timeline response headers, returns the path to the next batch""" links = headers.get('Link', '') @@ -309,6 +338,14 @@ def _get_next_path(headers): return "?".join([parsed.path, parsed.query]) +def _get_next_url(headers) -> Optional[str]: + """Given timeline response headers, returns the url to the next batch""" + links = headers.get('Link', '') + match = re.match('<([^>]+)>; rel="next"', links) + if match: + return match.group(1) + + def _timeline_generator(app, user, path, params=None): while path: response = http.get(app, user, path, params) @@ -369,7 +406,7 @@ def conversation_timeline_generator(app, user, limit=20): return _conversation_timeline_generator(app, user, path, params) -def account_timeline_generator(app: App, user: User, account_name: str, replies=False, reblogs=False, limit=20): +def account_timeline_generator(app, user, account_name: str, replies=False, reblogs=False, limit=20): account = find_account(app, user, account_name) path = f"/api/v1/accounts/{account['id']}/statuses" params = {"limit": limit, "exclude_replies": not replies, "exclude_reblogs": not reblogs} @@ -381,24 +418,23 @@ def timeline_list_generator(app, user, list_id, limit=20): return _timeline_generator(app, user, path, {'limit': limit}) -def _anon_timeline_generator(instance, path, params=None): - while path: - url = f"https://{instance}{path}" +def _anon_timeline_generator(url, params=None): + while url: response = http.anon_get(url, params) yield response.json() - path = _get_next_path(response.headers) + url = _get_next_url(response.headers) -def anon_public_timeline_generator(instance, local=False, limit=20): - path = '/api/v1/timelines/public' - params = {'local': str_bool(local), 'limit': limit} - return _anon_timeline_generator(instance, path, params) +def anon_public_timeline_generator(base_url, local=False, limit=20): + query = urlencode({"local": str_bool(local), "limit": limit}) + url = f"{base_url}/api/v1/timelines/public?{query}" + return _anon_timeline_generator(url) -def anon_tag_timeline_generator(instance, hashtag, local=False, limit=20): - path = f"/api/v1/timelines/tag/{quote(hashtag)}" - params = {'local': str_bool(local), 'limit': limit} - return _anon_timeline_generator(instance, path, params) +def anon_tag_timeline_generator(base_url, hashtag, local=False, limit=20): + query = urlencode({"local": str_bool(local), "limit": limit}) + url = f"{base_url}/api/v1/timelines/tag/{quote(hashtag)}?{query}" + return _anon_timeline_generator(url) def get_media(app: App, user: User, id: str): @@ -538,8 +574,8 @@ def verify_credentials(app, user) -> Response: return http.get(app, user, '/api/v1/accounts/verify_credentials') -def get_notifications(app, user, exclude_types=[], limit=20): - params = {"exclude_types[]": exclude_types, "limit": limit} +def get_notifications(app, user, types=[], exclude_types=[], limit=20): + params = {"types[]": types, "exclude_types[]": exclude_types, "limit": limit} return http.get(app, user, '/api/v1/notifications', params).json() @@ -570,7 +606,7 @@ def get_list_accounts(app, user, list_id): return _get_response_list(app, user, path) -def create_list(app, user, title, replies_policy): +def create_list(app, user, title, replies_policy="none"): url = "/api/v1/lists" json = {'title': title} if replies_policy: diff --git a/toot/cli/__init__.py b/toot/cli/__init__.py index 2e6451c..d4d14ed 100644 --- a/toot/cli/__init__.py +++ b/toot/cli/__init__.py @@ -1,9 +1,11 @@ -from toot.cli.base import cli, Context # noqa +# flake8: noqa +from toot.cli.base import cli, Context -from toot.cli.auth import * from toot.cli.accounts import * +from toot.cli.auth import * from toot.cli.lists import * from toot.cli.post import * from toot.cli.read import * from toot.cli.statuses import * from toot.cli.tags import * +from toot.cli.timelines import * diff --git a/toot/cli/base.py b/toot/cli/base.py index c86b531..2cd86b9 100644 --- a/toot/cli/base.py +++ b/toot/cli/base.py @@ -40,7 +40,7 @@ CONTEXT = dict( # Data object to add to Click context class Context(NamedTuple): - app: Optional[App] = None + app: Optional[App] user: Optional[User] = None color: bool = False debug: bool = False diff --git a/toot/cli/read.py b/toot/cli/read.py index f83d449..c68eaea 100644 --- a/toot/cli/read.py +++ b/toot/cli/read.py @@ -74,6 +74,7 @@ def instance(ctx: Context, instance_url: Optional[str], json: bool): @json_option @pass_context def search(ctx: Context, query: str, resolve: bool, json: bool): + """Search for users or hashtags""" response = api.search(ctx.app, ctx.user, query, resolve) if json: print(response.text) diff --git a/toot/cli/timelines.py b/toot/cli/timelines.py new file mode 100644 index 0000000..ffb382d --- /dev/null +++ b/toot/cli/timelines.py @@ -0,0 +1,180 @@ +import sys +import click + +from toot import api +from toot.cli.base import cli, pass_context, Context +from typing import Optional +from toot.cli.validators import validate_instance + +from toot.entities import Notification, Status, from_dict +from toot.output import print_notifications, print_timeline + + +@cli.command() +@click.option( + "--instance", "-i", + callback=validate_instance, + help="""Domain or base URL of the instance from which to read, + e.g. 'mastodon.social' or 'https://mastodon.social'""", +) +@click.option("--account", "-a", help="Show account timeline") +@click.option("--list", help="Show list timeline") +@click.option("--tag", "-t", help="Show hashtag timeline") +@click.option("--public", "-p", is_flag=True, help="Show public timeline") +@click.option( + "--local", "-l", is_flag=True, + help="Show only statuses from the local instance (public and tag timelines only)" +) +@click.option( + "--reverse", "-r", is_flag=True, + help="Reverse the order of the shown timeline (new posts at the bottom)" +) +@click.option( + "--once", "-1", is_flag=True, + help="Only show the first toots, do not prompt to continue" +) +@click.option( + "--count", "-c", type=int, default=10, + help="Number of posts per page (max 20)" +) +@pass_context +def timeline( + ctx: Context, + instance: Optional[str], + account: Optional[str], + list: Optional[str], + tag: Optional[str], + public: bool, + local: bool, + reverse: bool, + once: bool, + count: int, +): + """Show recent items in a timeline + + By default shows the home timeline. + """ + if len([arg for arg in [tag, list, public, account] if arg]) > 1: + raise click.ClickException("Only one of --public, --tag, --account, or --list can be used at one time.") + + if local and not (public or tag): + raise click.ClickException("The --local option is only valid alongside --public or --tag.") + + if instance and not (public or tag): + raise click.ClickException("The --instance option is only valid alongside --public or --tag.") + + list_id = _get_list_id(ctx, list) + + """Show recent statuses in a timeline""" + generator = api.get_timeline_generator( + ctx.app, + ctx.user, + base_url=instance, + account=account, + list_id=list_id, + tag=tag, + public=public, + local=local, + limit=count, + ) + + _show_timeline(generator, reverse, once) + + +@cli.command() +@click.option( + "--reverse", "-r", is_flag=True, + help="Reverse the order of the shown timeline (new posts at the bottom)" +) +@click.option( + "--once", "-1", is_flag=True, + help="Only show the first toots, do not prompt to continue" +) +@click.option( + "--count", "-c", type=int, default=10, + help="Number of posts per page (max 20)" +) +@pass_context +def bookmarks( + ctx: Context, + reverse: bool, + once: bool, + count: int, +): + """Show recent statuses in a timeline""" + generator = api.bookmark_timeline_generator(ctx.app, ctx.user, limit=count) + _show_timeline(generator, reverse, once) + + +@cli.command() +@click.option("--clear", help="Dismiss all notifications and exit") +@click.option( + "--reverse", "-r", is_flag=True, + help="Reverse the order of the shown notifications (newest on top)" +) +@click.option( + "--mentions", "-m", is_flag=True, + help="Show only mentions" +) +@pass_context +def notifications( + ctx: Context, + clear: bool, + reverse: bool, + mentions: int, +): + """Show notifications""" + if clear: + api.clear_notifications(ctx.app, ctx.user) + click.secho("✓ Notifications cleared", fg="green") + return + + exclude = [] + if mentions: + # Filter everything except mentions + # https://docs.joinmastodon.org/methods/notifications/ + exclude = ["follow", "favourite", "reblog", "poll", "follow_request"] + + notifications = api.get_notifications(ctx.app, ctx.user, exclude_types=exclude) + + if not notifications: + click.echo("You have no notifications") + return + + if reverse: + notifications = reversed(notifications) + + notifications = [from_dict(Notification, n) for n in notifications] + print_notifications(notifications) + + +def _show_timeline(generator, reverse, once): + while True: + try: + items = next(generator) + except StopIteration: + click.echo("That's all folks.") + return + + if reverse: + items = reversed(items) + + statuses = [from_dict(Status, item) for item in items] + print_timeline(statuses) + + if once or not sys.stdout.isatty(): + break + + char = input("\nContinue? [Y/n] ") + if char.lower() == "n": + break + + +def _get_list_id(ctx: Context, value: Optional[str]) -> Optional[str]: + if not value: + return None + + lists = api.get_lists(ctx.app, ctx.user) + for list in lists: + if list["id"] == value or list["title"] == value: + return list["id"] From 84396fefc2faf3e7f665f1ae04d77680d551fb58 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Sun, 3 Dec 2023 13:31:26 +0100 Subject: [PATCH 15/59] Improve variable naming --- toot/cli/post.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/toot/cli/post.py b/toot/cli/post.py index d19fe41..ae61a6e 100644 --- a/toot/cli/post.py +++ b/toot/cli/post.py @@ -24,13 +24,13 @@ from toot.utils.datetime import parse_datetime multiple=True ) @click.option( - "--description", "-d", + "--description", "-d", "descriptions", help="""Plain-text description of the media for accessibility purposes, one per attached media""", multiple=True, ) @click.option( - "--thumbnail", + "--thumbnail", "thumbnails", help="Path to an image file to serve as media thumbnail, one per attached media", type=click.File(mode="rb"), multiple=True @@ -111,8 +111,8 @@ def post( ctx: Context, text: Optional[str], media: Tuple[str], - description: Tuple[str], - thumbnail: Tuple[str], + descriptions: Tuple[str], + thumbnails: Tuple[str], visibility: str, sensitive: bool, spoiler_text: Optional[str], @@ -135,7 +135,7 @@ def post( if len(media) > 4: raise click.ClickException("Cannot attach more than 4 files.") - media_ids = _upload_media(ctx.app, ctx.user, media, description, thumbnail) + media_ids = _upload_media(ctx.app, ctx.user, media, descriptions, thumbnails) status_text = _get_status_text(text, editor, media) scheduled_at = _get_scheduled_at(scheduled_at, scheduled_in) @@ -201,11 +201,11 @@ def _get_scheduled_at(scheduled_at, scheduled_in): return None -def _upload_media(app, user, media, description, thumbnail): - # Match media to corresponding description and thumbnail +def _upload_media(app, user, media, descriptions, thumbnails): + # Match media to corresponding descriptions and thumbnail media = media or [] - descriptions = description or [] - thumbnails = thumbnail or [] + descriptions = descriptions or [] + thumbnails = thumbnails or [] uploaded_media = [] for idx, file in enumerate(media): From 3947b28de5317c202db771e97c23e674eefe2b0b Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Sun, 3 Dec 2023 13:45:24 +0100 Subject: [PATCH 16/59] Add upload command --- toot/api.py | 2 +- toot/cli/post.py | 34 ++++++++++++++++++++++++++++++++-- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/toot/api.py b/toot/api.py index 34b6bbd..f9ca29e 100644 --- a/toot/api.py +++ b/toot/api.py @@ -457,7 +457,7 @@ def upload_media( "thumbnail": _add_mime_type(thumbnail) }) - return http.post(app, user, "/api/v2/media", data=data, files=files).json() + return http.post(app, user, "/api/v2/media", data=data, files=files) def _add_mime_type(file): diff --git a/toot/cli/post.py b/toot/cli/post.py index ae61a6e..80c6bdd 100644 --- a/toot/cli/post.py +++ b/toot/cli/post.py @@ -4,12 +4,13 @@ import click import os from datetime import datetime, timedelta, timezone -from typing import Optional, Tuple +from typing import BinaryIO, 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.entities import MediaAttachment, from_dict from toot.utils import EOF_KEY, delete_tmp_status_file, editor_input, multiline_input from toot.utils.datetime import parse_datetime @@ -174,6 +175,36 @@ def post( delete_tmp_status_file() +@cli.command() +@click.argument("file", type=click.File(mode="rb")) +@click.option( + "--description", "-d", + help="Plain-text description of the media for accessibility purposes" +) +@json_option +@pass_context +def upload( + ctx: Context, + file: BinaryIO, + description: Optional[str], + json: bool, +): + """Upload an image or video file + + This is probably not very useful, see `toot post --media` instead. + """ + response = _do_upload(ctx.app, ctx.user, file, description, None) + if json: + click.echo(response.text) + else: + media = from_dict(MediaAttachment, response.json()) + click.echo() + click.echo(f"Successfully uploaded media ID {media.id}, type '{media.type}'") + click.echo(f"URL: {media.url}") + click.echo(f"Preview URL: {media.preview_url}") + + + def _get_status_text(text, editor, media): isatty = sys.stdin.isatty() @@ -220,7 +251,6 @@ def _upload_media(app, user, media, descriptions, thumbnails): def _do_upload(app, user, file, description, thumbnail): - click.echo(f"Uploading media: {file.name}") return api.upload_media(app, user, file, description=description, thumbnail=thumbnail) From 4dfab69f3b0fd82b39b5eab82b74703c43ca8977 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Sun, 3 Dec 2023 13:53:52 +0100 Subject: [PATCH 17/59] Add tui command --- toot/cli/__init__.py | 1 + toot/cli/tui.py | 16 ++++++++++++++++ toot/tui/app.py | 17 ++++++++++++----- toot/tui/compose.py | 2 +- 4 files changed, 30 insertions(+), 6 deletions(-) create mode 100644 toot/cli/tui.py diff --git a/toot/cli/__init__.py b/toot/cli/__init__.py index d4d14ed..2499e0e 100644 --- a/toot/cli/__init__.py +++ b/toot/cli/__init__.py @@ -9,3 +9,4 @@ from toot.cli.read import * from toot.cli.statuses import * from toot.cli.tags import * from toot.cli.timelines import * +from toot.cli.tui import * diff --git a/toot/cli/tui.py b/toot/cli/tui.py new file mode 100644 index 0000000..bfc37d5 --- /dev/null +++ b/toot/cli/tui.py @@ -0,0 +1,16 @@ +from typing import NamedTuple +import click +from toot.cli.base import Context, cli, pass_context +from toot.tui.app import TUI, TuiOptions + +@cli.command() +@click.option( + "--relative-datetimes", + is_flag=True, + help="Show relative datetimes in status list" +) +@pass_context +def tui(ctx: Context, relative_datetimes: bool): + """Launches the toot terminal user interface""" + options = TuiOptions(relative_datetimes, ctx.color) + TUI.create(ctx.app, ctx.user, options).run() diff --git a/toot/tui/app.py b/toot/tui/app.py index 8a2ce09..77cbaaf 100644 --- a/toot/tui/app.py +++ b/toot/tui/app.py @@ -3,9 +3,11 @@ import subprocess import urwid from concurrent.futures import ThreadPoolExecutor +from typing import NamedTuple from toot import api, config, __version__, settings -from toot.console import get_default_visibility +from toot import App, User +from toot.cli.base import get_default_visibility from toot.exceptions import ApiError from .compose import StatusComposer @@ -25,6 +27,11 @@ urwid.set_encoding('UTF-8') DEFAULT_MAX_TOOT_CHARS = 500 +class TuiOptions(NamedTuple): + relative_datetimes: bool + color: bool + + class Header(urwid.WidgetWrap): def __init__(self, app, user): self.app = app @@ -80,7 +87,7 @@ class TUI(urwid.Frame): screen: urwid.BaseScreen @staticmethod - def create(app, user, args): + def create(app: App, user: User, args: TuiOptions): """Factory method, sets up TUI and an event loop.""" screen = TUI.create_screen(args) tui = TUI(app, user, screen, args) @@ -102,18 +109,18 @@ class TUI(urwid.Frame): return tui @staticmethod - def create_screen(args): + def create_screen(args: TuiOptions): screen = urwid.raw_display.Screen() # Determine how many colors to use - default_colors = 1 if args.no_color else 16 + default_colors = 16 if args.color else 1 colors = settings.get_setting("tui.colors", int, default_colors) logger.debug(f"Setting colors to {colors}") screen.set_terminal_properties(colors) return screen - def __init__(self, app, user, screen, args): + def __init__(self, app, user, screen, args: TuiOptions): self.app = app self.user = user self.args = args diff --git a/toot/tui/compose.py b/toot/tui/compose.py index 05bfaaf..4d4b77a 100644 --- a/toot/tui/compose.py +++ b/toot/tui/compose.py @@ -1,7 +1,7 @@ import urwid import logging -from toot.console import get_default_visibility +from toot.cli.base import get_default_visibility from .constants import VISIBILITY_OPTIONS from .widgets import Button, EditBox From 452b98d2ad1f8c700aa9f8ac7826930c88da3644 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Mon, 4 Dec 2023 17:51:06 +0100 Subject: [PATCH 18/59] Delete old command implementations --- toot/commands.py | 669 -------------------------------- toot/console.py | 966 ----------------------------------------------- 2 files changed, 1635 deletions(-) delete mode 100644 toot/commands.py delete mode 100644 toot/console.py diff --git a/toot/commands.py b/toot/commands.py deleted file mode 100644 index d564cbf..0000000 --- a/toot/commands.py +++ /dev/null @@ -1,669 +0,0 @@ -from itertools import chain -import json -import sys -import platform - -from datetime import datetime, timedelta, timezone -from time import sleep, time - -from toot import api, config, __version__ -from toot.auth import login_interactive, login_browser_interactive, create_app_interactive -from toot.entities import Account, Instance, Notification, Status, from_dict -from toot.exceptions import ApiError, ConsoleError -from toot.output import (print_lists, print_out, print_instance, print_account, print_acct_list, - print_search_results, print_status, print_table, print_timeline, print_notifications, - print_tag_list, print_list_accounts, print_user_list) -from toot.utils import args_get_instance, delete_tmp_status_file, editor_input, multiline_input, EOF_KEY -from toot.utils.datetime import parse_datetime - - -def get_timeline_generator(app, user, args): - if len([arg for arg in [args.tag, args.list, args.public, args.account] if arg]) > 1: - raise ConsoleError("Only one of --public, --tag, --account, or --list can be used at one time.") - - if args.local and not (args.public or args.tag): - raise ConsoleError("The --local option is only valid alongside --public or --tag.") - - if args.instance and not (args.public or args.tag): - raise ConsoleError("The --instance option is only valid alongside --public or --tag.") - - if args.public: - if args.instance: - return api.anon_public_timeline_generator(args.instance, local=args.local, limit=args.count) - else: - return api.public_timeline_generator(app, user, local=args.local, limit=args.count) - elif args.tag: - if args.instance: - return api.anon_tag_timeline_generator(args.instance, args.tag, limit=args.count) - else: - return api.tag_timeline_generator(app, user, args.tag, local=args.local, limit=args.count) - elif args.account: - return api.account_timeline_generator(app, user, args.account, limit=args.count) - elif args.list: - return api.timeline_list_generator(app, user, args.list, limit=args.count) - else: - return api.home_timeline_generator(app, user, limit=args.count) - - -def timeline(app, user, args, generator=None): - if not generator: - generator = get_timeline_generator(app, user, args) - - while True: - try: - items = next(generator) - except StopIteration: - print_out("That's all folks.") - return - - if args.reverse: - items = reversed(items) - - statuses = [from_dict(Status, item) for item in items] - print_timeline(statuses) - - if args.once or not sys.stdout.isatty(): - break - - char = input("\nContinue? [Y/n] ") - if char.lower() == "n": - break - - -def status(app, user, args): - response = api.fetch_status(app, user, args.status_id) - if args.json: - print(response.text) - else: - status = from_dict(Status, response.json()) - print_status(status) - - -def thread(app, user, args): - context_response = api.context(app, user, args.status_id) - - if args.json: - print(context_response.text) - else: - toot = api.fetch_status(app, user, args.status_id).json() - context = context_response.json() - - statuses = chain(context["ancestors"], [toot], context["descendants"]) - print_timeline(from_dict(Status, s) for s in statuses) - - -def post(app, user, args): - if args.editor and not sys.stdin.isatty(): - raise ConsoleError("Cannot run editor if not in tty.") - - if args.media and len(args.media) > 4: - raise ConsoleError("Cannot attach more than 4 files.") - - media_ids = _upload_media(app, user, args) - status_text = _get_status_text(args.text, args.editor, args.media) - scheduled_at = _get_scheduled_at(args.scheduled_at, args.scheduled_in) - - if not status_text and not media_ids: - raise ConsoleError("You must specify either text or media to post.") - - response = api.post_status( - app, user, status_text, - visibility=args.visibility, - media_ids=media_ids, - sensitive=args.sensitive, - spoiler_text=args.spoiler_text, - in_reply_to_id=args.reply_to, - language=args.language, - scheduled_at=scheduled_at, - content_type=args.content_type, - poll_options=args.poll_option, - poll_expires_in=args.poll_expires_in, - poll_multiple=args.poll_multiple, - poll_hide_totals=args.poll_hide_totals, - ) - - if args.json: - print(response.text) - else: - status = response.json() - if "scheduled_at" in status: - scheduled_at = parse_datetime(status["scheduled_at"]) - scheduled_at = datetime.strftime(scheduled_at, "%Y-%m-%d %H:%M:%S%z") - print_out(f"Toot scheduled for: {scheduled_at}") - else: - print_out(f"Toot posted: {status['url']}") - - delete_tmp_status_file() - - -def _get_status_text(text, editor, media): - isatty = sys.stdin.isatty() - - if not text and not isatty: - text = sys.stdin.read().rstrip() - - if isatty: - if editor: - text = editor_input(editor, text) - elif not text and not media: - print_out("Write or paste your toot. Press {} to post it.".format(EOF_KEY)) - text = multiline_input() - - return text - - -def _get_scheduled_at(scheduled_at, scheduled_in): - if scheduled_at: - return scheduled_at - - if scheduled_in: - scheduled_at = datetime.now(timezone.utc) + timedelta(seconds=scheduled_in) - return scheduled_at.replace(microsecond=0).isoformat() - - return None - - -def _upload_media(app, user, args): - # Match media to corresponding description and thumbnail - media = args.media or [] - descriptions = args.description or [] - thumbnails = args.thumbnail or [] - uploaded_media = [] - - for idx, file in enumerate(media): - description = descriptions[idx].strip() if idx < len(descriptions) else None - thumbnail = thumbnails[idx] if idx < len(thumbnails) else None - result = _do_upload(app, user, file, description, thumbnail) - uploaded_media.append(result) - - _wait_until_all_processed(app, user, uploaded_media) - - return [m["id"] for m in uploaded_media] - - -def _wait_until_all_processed(app, user, uploaded_media): - """ - Media is uploaded asynchronously, and cannot be attached until the server - has finished processing it. This function waits for that to happen. - - Once media is processed, it will have the URL populated. - """ - if all(m["url"] for m in uploaded_media): - return - - # Timeout after waiting 1 minute - start_time = time() - timeout = 60 - - print_out("Waiting for media to finish processing...") - for media in uploaded_media: - _wait_until_processed(app, user, media, start_time, timeout) - - -def _wait_until_processed(app, user, media, start_time, timeout): - if media["url"]: - return - - media = api.get_media(app, user, media["id"]) - while not media["url"]: - sleep(1) - if time() > start_time + timeout: - raise ConsoleError(f"Media not processed by server after {timeout} seconds. Aborting.") - media = api.get_media(app, user, media["id"]) - - -def delete(app, user, args): - response = api.delete_status(app, user, args.status_id) - if args.json: - print(response.text) - else: - print_out("✓ Status deleted") - - -def favourite(app, user, args): - response = api.favourite(app, user, args.status_id) - if args.json: - print(response.text) - else: - print_out("✓ Status favourited") - - -def unfavourite(app, user, args): - response = api.unfavourite(app, user, args.status_id) - if args.json: - print(response.text) - else: - print_out("✓ Status unfavourited") - - -def reblog(app, user, args): - response = api.reblog(app, user, args.status_id, visibility=args.visibility) - if args.json: - print(response.text) - else: - print_out("✓ Status reblogged") - - -def unreblog(app, user, args): - response = api.unreblog(app, user, args.status_id) - if args.json: - print(response.text) - else: - print_out("✓ Status unreblogged") - - -def pin(app, user, args): - response = api.pin(app, user, args.status_id) - if args.json: - print(response.text) - else: - print_out("✓ Status pinned") - - -def unpin(app, user, args): - response = api.unpin(app, user, args.status_id) - if args.json: - print(response.text) - else: - print_out("✓ Status unpinned") - - -def bookmark(app, user, args): - response = api.bookmark(app, user, args.status_id) - if args.json: - print(response.text) - else: - print_out("✓ Status bookmarked") - - -def unbookmark(app, user, args): - response = api.unbookmark(app, user, args.status_id) - if args.json: - print(response.text) - else: - print_out("✓ Status unbookmarked") - - -def bookmarks(app, user, args): - timeline(app, user, args, api.bookmark_timeline_generator(app, user, limit=args.count)) - - -def reblogged_by(app, user, args): - response = api.reblogged_by(app, user, args.status_id) - - if args.json: - print(response.text) - else: - headers = ["Account", "Display name"] - rows = [[a["acct"], a["display_name"]] for a in response.json()] - print_table(headers, rows) - - -def auth(app, user, args): - 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("* {} {}".format(uid, active_label)) - - path = config.get_config_file_path() - print_out("\nAuth tokens are stored in: {}".format(path)) - - -def env(app, user, args): - print_out(f"toot {__version__}") - print_out(f"Python {sys.version}") - print_out(platform.platform()) - - -def update_account(app, user, args): - options = [ - args.avatar, - args.bot, - args.discoverable, - args.display_name, - args.header, - args.language, - args.locked, - args.note, - args.privacy, - args.sensitive, - ] - - if all(option is None for option in options): - raise ConsoleError("Please specify at least one option to update the account") - - response = api.update_account( - app, - user, - avatar=args.avatar, - bot=args.bot, - discoverable=args.discoverable, - display_name=args.display_name, - header=args.header, - language=args.language, - locked=args.locked, - note=args.note, - privacy=args.privacy, - sensitive=args.sensitive, - ) - - if args.json: - print(response.text) - else: - print_out("✓ Account updated") - - -def login_cli(app, user, args): - base_url = args_get_instance(args.instance, args.scheme) - app = create_app_interactive(base_url) - login_interactive(app, args.email) - - print_out() - print_out("✓ Successfully logged in.") - - -def login(app, user, args): - base_url = args_get_instance(args.instance, args.scheme) - app = create_app_interactive(base_url) - login_browser_interactive(app) - - print_out() - print_out("✓ Successfully logged in.") - - -def logout(app, user, args): - user = config.load_user(args.account, throw=True) - config.delete_user(user) - print_out("✓ User {} logged out".format(config.user_id(user))) - - -def activate(app, user, args): - if not args.account: - print_out("Specify one of the following user accounts to activate:\n") - print_user_list(config.get_user_list()) - return - - user = config.load_user(args.account, throw=True) - config.activate_user(user) - print_out("✓ User {} active".format(config.user_id(user))) - - -def upload(app, user, args): - response = _do_upload(app, user, args.file, args.description, None) - - msg = "Successfully uploaded media ID {}, type '{}'" - - print_out() - print_out(msg.format(response['id'], response['type'])) - print_out("URL: {}".format(response['url'])) - print_out("Preview URL: {}".format(response['preview_url'])) - - -def search(app, user, args): - response = api.search(app, user, args.query, args.resolve) - if args.json: - print(response.text) - else: - print_search_results(response.json()) - - -def _do_upload(app, user, file, description, thumbnail): - print_out("Uploading media: {}".format(file.name)) - return api.upload_media(app, user, file, description=description, thumbnail=thumbnail) - - -def follow(app, user, args): - account = api.find_account(app, user, args.account) - response = api.follow(app, user, account["id"]) - if args.json: - print(response.text) - else: - print_out(f"✓ You are now following {args.account}") - - -def unfollow(app, user, args): - account = api.find_account(app, user, args.account) - response = api.unfollow(app, user, account["id"]) - if args.json: - print(response.text) - else: - print_out(f"✓ You are no longer following {args.account}") - - -def following(app, user, args): - account = args.account or user.username - account = api.find_account(app, user, account) - accounts = api.following(app, user, account["id"]) - if args.json: - print(json.dumps(accounts)) - else: - print_acct_list(accounts) - - -def followers(app, user, args): - account = args.account or user.username - account = api.find_account(app, user, account) - accounts = api.followers(app, user, account["id"]) - if args.json: - print(json.dumps(accounts)) - else: - print_acct_list(accounts) - - -def tags_follow(app, user, args): - tn = args.tag_name if not args.tag_name.startswith("#") else args.tag_name[1:] - api.follow_tag(app, user, tn) - print_out("✓ You are now following #{}".format(tn)) - - -def tags_unfollow(app, user, args): - tn = args.tag_name if not args.tag_name.startswith("#") else args.tag_name[1:] - api.unfollow_tag(app, user, tn) - print_out("✓ You are no longer following #{}".format(tn)) - - -def tags_followed(app, user, args): - response = api.followed_tags(app, user) - print_tag_list(response) - - -def lists(app, user, args): - lists = api.get_lists(app, user) - - if lists: - print_lists(lists) - else: - print_out("You have no lists defined.") - - -def list_accounts(app, user, args): - list_id = _get_list_id(app, user, args) - response = api.get_list_accounts(app, user, list_id) - print_list_accounts(response) - - -def list_create(app, user, args): - api.create_list(app, user, title=args.title, replies_policy=args.replies_policy) - print_out(f"✓ List \"{args.title}\" created.") - - -def list_delete(app, user, args): - list_id = _get_list_id(app, user, args) - api.delete_list(app, user, list_id) - print_out(f"✓ List \"{args.title if args.title else args.id}\" deleted.") - - -def list_add(app, user, args): - list_id = _get_list_id(app, user, args) - account = api.find_account(app, user, args.account) - - try: - api.add_accounts_to_list(app, user, list_id, [account['id']]) - except Exception as ex: - # if we failed to add the account, try to give a - # more specific error message than "record not found" - my_accounts = api.followers(app, user, account['id']) - found = False - if my_accounts: - for my_account in my_accounts: - if my_account['id'] == account['id']: - found = True - break - if found is False: - print_out(f"You must follow @{account['acct']} before adding this account to a list.") - else: - print_out(f"{ex}") - return - - print_out(f"✓ Added account \"{args.account}\"") - - -def list_remove(app, user, args): - list_id = _get_list_id(app, user, args) - account = api.find_account(app, user, args.account) - api.remove_accounts_from_list(app, user, list_id, [account['id']]) - print_out(f"✓ Removed account \"{args.account}\"") - - -def _get_list_id(app, user, args): - list_id = args.id or api.find_list_id(app, user, args.title) - if not list_id: - raise ConsoleError("List not found") - return list_id - - -def mute(app, user, args): - account = api.find_account(app, user, args.account) - response = api.mute(app, user, account['id']) - if args.json: - print(response.text) - else: - print_out("✓ You have muted {}".format(args.account)) - - -def unmute(app, user, args): - account = api.find_account(app, user, args.account) - response = api.unmute(app, user, account['id']) - if args.json: - print(response.text) - else: - print_out("✓ {} is no longer muted".format(args.account)) - - -def muted(app, user, args): - response = api.muted(app, user) - if args.json: - print(json.dumps(response)) - else: - if len(response) > 0: - print("Muted accounts:") - print_acct_list(response) - else: - print("No accounts muted") - - -def block(app, user, args): - account = api.find_account(app, user, args.account) - response = api.block(app, user, account['id']) - if args.json: - print(response.text) - else: - print_out("✓ You are now blocking {}".format(args.account)) - - -def unblock(app, user, args): - account = api.find_account(app, user, args.account) - response = api.unblock(app, user, account['id']) - if args.json: - print(response.text) - else: - print_out("✓ {} is no longer blocked".format(args.account)) - - -def blocked(app, user, args): - response = api.blocked(app, user) - if args.json: - print(json.dumps(response)) - else: - if len(response) > 0: - print("Blocked accounts:") - print_acct_list(response) - else: - print("No accounts blocked") - - -def whoami(app, user, args): - response = api.verify_credentials(app, user) - if args.json: - print(response.text) - else: - account = from_dict(Account, response.json()) - print_account(account) - - -def whois(app, user, args): - account = api.find_account(app, user, args.account) - # Here it's not possible to avoid parsing json since it's needed to find the account. - if args.json: - print(json.dumps(account)) - else: - account = from_dict(Account, account) - print_account(account) - - -def instance(app, user, args): - 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: - response = api.get_instance(base_url) - except ApiError: - raise ConsoleError( - f"Instance not found at {base_url}.\n" - "The given domain probably does not host a Mastodon instance." - ) - - if args.json: - print(response.text) - else: - instance = from_dict(Instance, response.json()) - print_instance(instance) - - -def notifications(app, user, args): - if args.clear: - api.clear_notifications(app, user) - print_out("Cleared notifications") - return - - exclude = [] - if args.mentions: - # Filter everything except mentions - # https://docs.joinmastodon.org/methods/notifications/ - exclude = ["follow", "favourite", "reblog", "poll", "follow_request"] - notifications = api.get_notifications(app, user, exclude_types=exclude) - if not notifications: - print_out("No notification") - return - - if args.reverse: - notifications = reversed(notifications) - - notifications = [from_dict(Notification, n) for n in notifications] - print_notifications(notifications) - - -def tui(app, user, args): - from .tui.app import TUI - TUI.create(app, user, args).run() diff --git a/toot/console.py b/toot/console.py deleted file mode 100644 index 41583c1..0000000 --- a/toot/console.py +++ /dev/null @@ -1,966 +0,0 @@ -import logging -import os -import re -import shutil -import sys - -from argparse import ArgumentParser, FileType, ArgumentTypeError, Action -from collections import namedtuple -from itertools import chain -from toot import config, commands, CLIENT_NAME, CLIENT_WEBSITE, __version__, settings -from toot.exceptions import ApiError, ConsoleError -from toot.output import print_out, print_err -from toot.settings import get_setting - -VISIBILITY_CHOICES = ["public", "unlisted", "private", "direct"] -VISIBILITY_CHOICES_STR = ", ".join(f"'{v}'" for v in VISIBILITY_CHOICES) - -PRIVACY_CHOICES = ["public", "unlisted", "private"] -PRIVACY_CHOICES_STR = ", ".join(f"'{v}'" for v in PRIVACY_CHOICES) - - -class BooleanOptionalAction(Action): - """ - Backported from argparse. This action is available since Python 3.9. - https://github.com/python/cpython/blob/3.11/Lib/argparse.py - """ - def __init__(self, - option_strings, - dest, - default=None, - type=None, - choices=None, - required=False, - help=None, - metavar=None): - - _option_strings = [] - for option_string in option_strings: - _option_strings.append(option_string) - - if option_string.startswith('--'): - option_string = '--no-' + option_string[2:] - _option_strings.append(option_string) - - super().__init__( - option_strings=_option_strings, - dest=dest, - nargs=0, - default=default, - type=type, - choices=choices, - required=required, - help=help, - metavar=metavar) - - def __call__(self, parser, namespace, values, option_string=None): - if option_string in self.option_strings: - setattr(namespace, self.dest, not option_string.startswith('--no-')) - - def format_usage(self): - return ' | '.join(self.option_strings) - - -def get_default_visibility(): - return os.getenv("TOOT_POST_VISIBILITY", "public") - - -def language(value): - """Validates the language parameter""" - if len(value) != 2: - raise ArgumentTypeError( - "Invalid language. Expected a 2 letter abbreviation according to " - "the ISO 639-1 standard." - ) - - return value - - -def visibility(value): - """Validates the visibility parameter""" - if value not in VISIBILITY_CHOICES: - raise ValueError("Invalid visibility value") - - return value - - -def privacy(value): - """Validates the privacy parameter""" - if value not in PRIVACY_CHOICES: - raise ValueError(f"Invalid privacy value. Expected one of {PRIVACY_CHOICES_STR}.") - - return value - - -def timeline_count(value): - n = int(value) - if not 0 < n <= 20: - raise ArgumentTypeError("Number of toots should be between 1 and 20.") - return n - - -DURATION_UNITS = { - "m": 60, - "h": 60 * 60, - "d": 60 * 60 * 24, -} - - -DURATION_EXAMPLES = """e.g. "1 day", "2 hours 30 minutes", "5 minutes 30 -seconds" or any combination of above. Shorthand: "1d", "2h30m", "5m30s\"""" - - -def duration(value: str): - match = re.match(r"""^ - (([0-9]+)\s*(days|day|d))?\s* - (([0-9]+)\s*(hours|hour|h))?\s* - (([0-9]+)\s*(minutes|minute|m))?\s* - (([0-9]+)\s*(seconds|second|s))?\s* - $""", value, re.X) - - if not match: - raise ArgumentTypeError(f"Invalid duration: {value}") - - days = match.group(2) - hours = match.group(5) - minutes = match.group(8) - seconds = match.group(11) - - days = int(match.group(2) or 0) * 60 * 60 * 24 - hours = int(match.group(5) or 0) * 60 * 60 - minutes = int(match.group(8) or 0) * 60 - seconds = int(match.group(11) or 0) - - duration = days + hours + minutes + seconds - - if duration == 0: - raise ArgumentTypeError("Empty duration") - - return duration - - -def editor(value): - if not value: - raise ArgumentTypeError( - "Editor not specified in --editor option and $EDITOR environment " - "variable not set." - ) - - # Check editor executable exists - exe = shutil.which(value) - if not exe: - raise ArgumentTypeError("Editor `{}` not found".format(value)) - - return exe - - -Command = namedtuple("Command", ["name", "description", "require_auth", "arguments"]) - - -# Arguments added to every command -common_args = [ - (["--no-color"], { - "help": "don't use ANSI colors in output", - "action": 'store_true', - "default": False, - }), - (["--quiet"], { - "help": "don't write to stdout on success", - "action": 'store_true', - "default": False, - }), - (["--debug"], { - "help": "show debug log in console", - "action": 'store_true', - "default": False, - }), - (["--verbose"], { - "help": "show extra detail in debug log; used with --debug", - "action": 'store_true', - "default": False, - }), -] - -# Arguments added to commands which require authentication -common_auth_args = [ - (["-u", "--using"], { - "help": "the account to use, overrides active account", - }), -] - -account_arg = (["account"], { - "help": "account name, e.g. 'Gargron@mastodon.social'", -}) - -optional_account_arg = (["account"], { - "nargs": "?", - "help": "account name, e.g. 'Gargron@mastodon.social'", -}) - -instance_arg = (["-i", "--instance"], { - "type": str, - "help": 'mastodon instance to log into e.g. "mastodon.social"', -}) - -email_arg = (["-e", "--email"], { - "type": str, - "help": 'email address to log in with', -}) - -scheme_arg = (["--disable-https"], { - "help": "disable HTTPS and use insecure HTTP", - "dest": "scheme", - "default": "https", - "action": "store_const", - "const": "http", -}) - -status_id_arg = (["status_id"], { - "help": "ID of the status", - "type": str, -}) - -visibility_arg = (["-v", "--visibility"], { - "type": visibility, - "default": get_default_visibility(), - "help": f"Post visibility. One of: {VISIBILITY_CHOICES_STR}. Defaults to " - f"'{get_default_visibility()}' which can be overridden by setting " - "the TOOT_POST_VISIBILITY environment variable", -}) - -tag_arg = (["tag_name"], { - "type": str, - "help": "tag name, e.g. Caturday, or \"#Caturday\"", -}) - -json_arg = (["--json"], { - "action": "store_true", - "default": False, - "help": "print json instead of plaintext", -}) - -# Arguments for selecting a timeline (see `toot.commands.get_timeline_generator`) -common_timeline_args = [ - (["-p", "--public"], { - "action": "store_true", - "default": False, - "help": "show public timeline (does not require auth)", - }), - (["-t", "--tag"], { - "type": str, - "help": "show hashtag timeline (does not require auth)", - }), - (["-a", "--account"], { - "type": str, - "help": "show timeline for the given account", - }), - (["-l", "--local"], { - "action": "store_true", - "default": False, - "help": "show only statuses from local instance (public and tag timelines only)", - }), - (["-i", "--instance"], { - "type": str, - "help": "mastodon instance from which to read (public and tag timelines only)", - }), - (["--list"], { - "type": str, - "help": "show timeline for given list.", - }), -] - -timeline_and_bookmark_args = [ - (["-c", "--count"], { - "type": timeline_count, - "help": "number of toots to show per page (1-20, default 10).", - "default": 10, - }), - (["-r", "--reverse"], { - "action": "store_true", - "default": False, - "help": "Reverse the order of the shown timeline (to new posts at the bottom)", - }), - (["-1", "--once"], { - "action": "store_true", - "default": False, - "help": "Only show the first toots, do not prompt to continue.", - }), -] - -timeline_args = common_timeline_args + timeline_and_bookmark_args - -AUTH_COMMANDS = [ - Command( - name="login", - description="Log into a mastodon instance using your browser (recommended)", - arguments=[instance_arg, scheme_arg], - require_auth=False, - ), - Command( - name="login_cli", - description="Log in from the console, does NOT support two factor authentication", - arguments=[instance_arg, email_arg, scheme_arg], - require_auth=False, - ), - Command( - name="activate", - description="Switch between logged in accounts.", - arguments=[optional_account_arg], - require_auth=False, - ), - Command( - name="logout", - description="Log out, delete stored access keys", - arguments=[account_arg], - require_auth=False, - ), - Command( - name="auth", - description="Show logged in accounts and instances", - arguments=[], - require_auth=False, - ), - Command( - name="env", - description="Print environment information for inclusion in bug reports.", - arguments=[], - require_auth=False, - ), - Command( - name="update_account", - description="Update your account details", - arguments=[ - (["--display-name"], { - "type": str, - "help": "The display name to use for the profile.", - }), - (["--note"], { - "type": str, - "help": "The account bio.", - }), - (["--avatar"], { - "type": FileType("rb"), - "help": "Path to the avatar image to set.", - }), - (["--header"], { - "type": FileType("rb"), - "help": "Path to the header image to set.", - }), - (["--bot"], { - "action": BooleanOptionalAction, - "help": "Whether the account has a bot flag.", - }), - (["--discoverable"], { - "action": BooleanOptionalAction, - "help": "Whether the account should be shown in the profile directory.", - }), - (["--locked"], { - "action": BooleanOptionalAction, - "help": "Whether manual approval of follow requests is required.", - }), - (["--privacy"], { - "type": privacy, - "help": f"Default post privacy for authored statuses. One of: {PRIVACY_CHOICES_STR}." - }), - (["--sensitive"], { - "action": BooleanOptionalAction, - "help": "Whether to mark authored statuses as sensitive by default." - }), - (["--language"], { - "type": language, - "help": "Default language to use for authored statuses (ISO 639-1)." - }), - json_arg, - ], - require_auth=True, - ), -] - -TUI_COMMANDS = [ - Command( - name="tui", - description="Launches the toot terminal user interface", - arguments=[ - (["--relative-datetimes"], { - "action": "store_true", - "default": False, - "help": "Show relative datetimes in status list.", - }), - ], - require_auth=True, - ), -] - - -READ_COMMANDS = [ - Command( - name="whoami", - description="Display logged in user details", - arguments=[json_arg], - require_auth=True, - ), - Command( - name="whois", - description="Display account details", - arguments=[ - (["account"], { - "help": "account name or numeric ID" - }), - json_arg, - ], - require_auth=True, - ), - Command( - name="notifications", - description="Notifications for logged in user", - arguments=[ - (["--clear"], { - "help": "delete all notifications from the server", - "action": 'store_true', - "default": False, - }), - (["-r", "--reverse"], { - "action": "store_true", - "default": False, - "help": "Reverse the order of the shown notifications (newest on top)", - }), - (["-m", "--mentions"], { - "action": "store_true", - "default": False, - "help": "Only print mentions", - }) - ], - require_auth=True, - ), - Command( - name="instance", - description="Display instance details", - arguments=[ - (["instance"], { - "help": "instance domain (e.g. 'mastodon.social') or blank to use current", - "nargs": "?", - }), - scheme_arg, - json_arg, - ], - require_auth=False, - ), - Command( - name="search", - description="Search for users or hashtags", - arguments=[ - (["query"], { - "help": "the search query", - }), - (["-r", "--resolve"], { - "action": 'store_true', - "default": False, - "help": "Resolve non-local accounts", - }), - json_arg, - ], - require_auth=True, - ), - Command( - name="thread", - description="Show toot thread items", - arguments=[ - (["status_id"], { - "help": "Show thread for toot.", - }), - json_arg, - ], - require_auth=True, - ), - Command( - name="status", - description="Show a single status", - arguments=[ - (["status_id"], { - "help": "ID of the status to show.", - }), - json_arg, - ], - require_auth=True, - ), - Command( - name="timeline", - description="Show recent items in a timeline (home by default)", - arguments=timeline_args, - require_auth=True, - ), - Command( - name="bookmarks", - description="Show bookmarked posts", - arguments=timeline_and_bookmark_args, - require_auth=True, - ), -] - -POST_COMMANDS = [ - Command( - name="post", - description="Post a status text to your timeline", - arguments=[ - (["text"], { - "help": "The status text to post.", - "nargs": "?", - }), - (["-m", "--media"], { - "action": "append", - "type": FileType("rb"), - "help": "path to the media file to attach (specify multiple " - "times to attach up to 4 files)" - }), - (["-d", "--description"], { - "action": "append", - "type": str, - "help": "plain-text description of the media for accessibility " - "purposes, one per attached media" - }), - (["--thumbnail"], { - "action": "append", - "type": FileType("rb"), - "help": "path to an image file to serve as media thumbnail, " - "one per attached media" - }), - visibility_arg, - (["-s", "--sensitive"], { - "action": 'store_true', - "default": False, - "help": "mark the media as NSFW", - }), - (["-p", "--spoiler-text"], { - "type": str, - "help": "text to be shown as a warning before the actual content", - }), - (["-r", "--reply-to"], { - "type": str, - "help": "local ID of the status you want to reply to", - }), - (["-l", "--language"], { - "type": language, - "help": "ISO 639-1 language code of the toot, to skip automatic detection", - }), - (["-e", "--editor"], { - "type": editor, - "nargs": "?", - "const": os.getenv("EDITOR", ""), # option given without value - "help": "Specify an editor to compose your toot, " - "defaults to editor defined in $EDITOR env variable.", - }), - (["--scheduled-at"], { - "type": str, - "help": "ISO 8601 Datetime at which to schedule a status. Must " - "be at least 5 minutes in the future.", - }), - (["--scheduled-in"], { - "type": duration, - "help": f"""Schedule the toot to be posted after a given amount - of time, {DURATION_EXAMPLES}. Must be at least 5 - minutes.""", - }), - (["-t", "--content-type"], { - "type": str, - "help": "MIME type for the status text (not supported on all instances)", - }), - (["--poll-option"], { - "action": "append", - "type": str, - "help": "Possible answer to the poll" - }), - (["--poll-expires-in"], { - "type": duration, - "help": f"""Duration that the poll should be open, - {DURATION_EXAMPLES}. Defaults to 24h.""", - "default": 24 * 60 * 60, - }), - (["--poll-multiple"], { - "action": "store_true", - "default": False, - "help": "Allow multiple answers to be selected." - }), - (["--poll-hide-totals"], { - "action": "store_true", - "default": False, - "help": "Hide vote counts until the poll ends. Defaults to false." - }), - json_arg, - ], - require_auth=True, - ), - Command( - name="upload", - description="Upload an image or video file", - arguments=[ - (["file"], { - "help": "Path to the file to upload", - "type": FileType('rb') - }), - (["-d", "--description"], { - "type": str, - "help": "plain-text description of the media for accessibility purposes" - }), - ], - require_auth=True, - ), -] - -STATUS_COMMANDS = [ - Command( - name="delete", - description="Delete a status", - arguments=[status_id_arg, json_arg], - require_auth=True, - ), - Command( - name="favourite", - description="Favourite a status", - arguments=[status_id_arg, json_arg], - require_auth=True, - ), - Command( - name="unfavourite", - description="Unfavourite a status", - arguments=[status_id_arg, json_arg], - require_auth=True, - ), - Command( - name="reblog", - description="Reblog a status", - arguments=[status_id_arg, visibility_arg, json_arg], - require_auth=True, - ), - Command( - name="unreblog", - description="Unreblog a status", - arguments=[status_id_arg, json_arg], - require_auth=True, - ), - Command( - name="reblogged_by", - description="Show accounts that reblogged the status", - arguments=[status_id_arg, json_arg], - require_auth=False, - ), - Command( - name="pin", - description="Pin a status", - arguments=[status_id_arg, json_arg], - require_auth=True, - ), - Command( - name="unpin", - description="Unpin a status", - arguments=[status_id_arg, json_arg], - require_auth=True, - ), - Command( - name="bookmark", - description="Bookmark a status", - arguments=[status_id_arg, json_arg], - require_auth=True, - ), - Command( - name="unbookmark", - description="Unbookmark a status", - arguments=[status_id_arg, json_arg], - require_auth=True, - ), -] - -ACCOUNTS_COMMANDS = [ - Command( - name="follow", - description="Follow an account", - arguments=[account_arg, json_arg], - require_auth=True, - ), - Command( - name="unfollow", - description="Unfollow an account", - arguments=[account_arg, json_arg], - require_auth=True, - ), - Command( - name="following", - description="List accounts followed by the given account, " + - "or your account if no account given", - arguments=[optional_account_arg, json_arg], - require_auth=True, - ), - Command( - name="followers", - description="List accounts following the given account, " + - "or your account if no account given", - arguments=[optional_account_arg, json_arg], - require_auth=True, - ), - Command( - name="mute", - description="Mute an account", - arguments=[account_arg, json_arg], - require_auth=True, - ), - Command( - name="unmute", - description="Unmute an account", - arguments=[account_arg, json_arg], - require_auth=True, - ), - Command( - name="muted", - description="List muted accounts", - arguments=[json_arg], - require_auth=True, - ), - Command( - name="block", - description="Block an account", - arguments=[account_arg, json_arg], - require_auth=True, - ), - Command( - name="unblock", - description="Unblock an account", - arguments=[account_arg, json_arg], - require_auth=True, - ), - Command( - name="blocked", - description="List blocked accounts", - arguments=[json_arg], - require_auth=True, - ), -] - -TAG_COMMANDS = [ - Command( - name="tags_followed", - description="List hashtags you follow", - arguments=[], - require_auth=True, - ), - Command( - name="tags_follow", - description="Follow a hashtag", - arguments=[tag_arg], - require_auth=True, - ), - Command( - name="tags_unfollow", - description="Unfollow a hashtag", - arguments=[tag_arg], - require_auth=True, - ), -] - -LIST_COMMANDS = [ - Command( - name="lists", - description="List all lists", - arguments=[], - require_auth=True, - ), - Command( - name="list_accounts", - description="List the accounts in a list", - arguments=[ - (["--id"], { - "type": str, - "help": "ID of the list" - }), - (["title"], { - "type": str, - "nargs": "?", - "help": "title of the list" - }), - ], - require_auth=True, - ), - Command( - name="list_create", - description="Create a list", - arguments=[ - (["title"], { - "type": str, - "help": "title of the list" - }), - (["--replies-policy"], { - "type": str, - "help": "replies policy: 'followed', 'list', or 'none' (defaults to 'none')" - }), - ], - require_auth=True, - ), - Command( - name="list_delete", - description="Delete a list", - arguments=[ - (["--id"], { - "type": str, - "help": "ID of the list" - }), - (["title"], { - "type": str, - "nargs": "?", - "help": "title of the list" - }), - ], - require_auth=True, - ), - Command( - name="list_add", - description="Add account to list", - arguments=[ - (["--id"], { - "type": str, - "help": "ID of the list" - }), - (["title"], { - "type": str, - "nargs": "?", - "help": "title of the list" - }), - (["account"], { - "type": str, - "help": "Account to add" - }), - ], - require_auth=True, - ), - Command( - name="list_remove", - description="Remove account from list", - arguments=[ - (["--id"], { - "type": str, - "help": "ID of the list" - }), - (["title"], { - "type": str, - "nargs": "?", - "help": "title of the list" - }), - (["account"], { - "type": str, - "help": "Account to remove" - }), - ], - require_auth=True, - ), -] -COMMAND_GROUPS = [ - ("Authentication", AUTH_COMMANDS), - ("TUI", TUI_COMMANDS), - ("Read", READ_COMMANDS), - ("Post", POST_COMMANDS), - ("Status", STATUS_COMMANDS), - ("Accounts", ACCOUNTS_COMMANDS), - ("Hashtags", TAG_COMMANDS), - ("Lists", LIST_COMMANDS), -] - -COMMANDS = list(chain(*[commands for _, commands in COMMAND_GROUPS])) - - -def print_usage(): - max_name_len = max(len(name) for name, _ in COMMAND_GROUPS) - - print_out("{}".format(CLIENT_NAME)) - print_out("v{}".format(__version__)) - - for name, cmds in COMMAND_GROUPS: - print_out("") - print_out(name + ":") - - for cmd in cmds: - cmd_name = cmd.name.ljust(max_name_len + 2) - print_out(" toot {} {}".format(cmd_name, cmd.description)) - - print_out("") - print_out("To get help for each command run:") - print_out(" toot \\ --help") - print_out("") - print_out("{}".format(CLIENT_WEBSITE)) - - -def get_argument_parser(name, command): - parser = ArgumentParser( - prog='toot %s' % name, - description=command.description, - epilog=CLIENT_WEBSITE) - - combined_args = command.arguments + common_args - if command.require_auth: - combined_args += common_auth_args - - defaults = get_setting(f"commands.{name}", dict, {}) - - for args, kwargs in combined_args: - # Set default value from settings if exists - default = get_default_value(defaults, args) - if default is not None: - kwargs["default"] = default - parser.add_argument(*args, **kwargs) - - return parser - - -def get_default_value(defaults, args): - # Hacky way to determine command name from argparse args - name = args[-1].lstrip("-").replace("-", "_") - return defaults.get(name) - - -def run_command(app, user, name, args): - command = next((c for c in COMMANDS if c.name == name), None) - - if not command: - print_err(f"Unknown command '{name}'") - print_out("Run toot --help to show a list of available commands.") - return - - 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.") - return - - fn = commands.__dict__.get(name) - - if not fn: - raise NotImplementedError("Command '{}' does not have an implementation.".format(name)) - - return fn(app, user, parsed_args) - - -def main(): - if settings.get_debug(): - filename = settings.get_debug_file() - logging.basicConfig(level=logging.DEBUG, filename=filename) - logging.getLogger("urllib3").setLevel(logging.INFO) - - command_name = sys.argv[1] if len(sys.argv) > 1 else None - args = sys.argv[2:] - - if not command_name or command_name == "--help": - return print_usage() - - user, app = config.get_active_user_app() - - try: - run_command(app, user, command_name, args) - except (ConsoleError, ApiError) as e: - print_err(str(e)) - sys.exit(1) - except KeyboardInterrupt: - pass From eaaa14cfc22feb7d7fec1a88c836214608488d18 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Mon, 4 Dec 2023 18:45:40 +0100 Subject: [PATCH 19/59] Use click.echo to output text --- toot/config.py | 3 --- toot/utils/__init__.py | 15 ++++++++------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/toot/config.py b/toot/config.py index 98ee6d8..4548ea5 100644 --- a/toot/config.py +++ b/toot/config.py @@ -7,7 +7,6 @@ from typing import Optional from toot import User, App, get_config_dir from toot.exceptions import ConsoleError -from toot.output import print_out TOOT_CONFIG_FILE_NAME = "config.json" @@ -30,8 +29,6 @@ def make_config(path): "active_user": None, } - print_out("Creating config file at {}".format(path)) - # Ensure dir exists os.makedirs(dirname(path), exist_ok=True) diff --git a/toot/utils/__init__.py b/toot/utils/__init__.py index c4afa7f..268918a 100644 --- a/toot/utils/__init__.py +++ b/toot/utils/__init__.py @@ -9,6 +9,8 @@ import warnings from bs4 import BeautifulSoup from typing import Dict +import click + from toot.exceptions import ConsoleError from urllib.parse import urlparse, urlencode, quote, unquote @@ -148,14 +150,13 @@ def _tmp_status_path() -> str: return f"{tmp_dir}/.status.toot" -def _use_existing_tmp_file(tmp_path) -> bool: - from toot.output import print_out - +def _use_existing_tmp_file(tmp_path: str) -> bool: if os.path.exists(tmp_path): - print_out(f"Found a draft status at: {tmp_path}") - print_out("[O]pen (default) or [D]elete? ", end="") - char = read_char(["o", "d"], "o") - return char == "o" + click.echo(f"Found draft status at: {tmp_path}") + + choice = click.Choice(["O", "D"], case_sensitive=False) + char = click.prompt("Open or Delete?", type=choice, default="O") + return char == "O" return False From b9d0c1f7c2b99ca1cc72b74094566f544adf525c Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Mon, 4 Dec 2023 18:46:45 +0100 Subject: [PATCH 20/59] Delete unused code --- toot/utils/__init__.py | 37 ------------------------------------- 1 file changed, 37 deletions(-) diff --git a/toot/utils/__init__.py b/toot/utils/__init__.py index 268918a..3464cf7 100644 --- a/toot/utils/__init__.py +++ b/toot/utils/__init__.py @@ -127,17 +127,6 @@ def editor_input(editor: str, initial_text: str): return f.read().split(EDITOR_DIVIDER)[0].strip() -def read_char(values, default): - values = [v.lower() for v in values] - - while True: - value = input().lower() - if value == "": - return default - if value in values: - return value - - def delete_tmp_status_file(): try: os.unlink(_tmp_status_path()) @@ -166,32 +155,6 @@ def drop_empty_values(data: Dict) -> Dict: 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" - ])) - - def urlencode_url(url): parsed_url = urlparse(url) From 24866bd4e417ccfdc656c64c63bfea21b0d19475 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Tue, 5 Dec 2023 08:15:27 +0100 Subject: [PATCH 21/59] Improve types --- toot/utils/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/toot/utils/__init__.py b/toot/utils/__init__.py index 3464cf7..d9ffbb9 100644 --- a/toot/utils/__init__.py +++ b/toot/utils/__init__.py @@ -7,7 +7,7 @@ import unicodedata import warnings from bs4 import BeautifulSoup -from typing import Dict +from typing import Any, Dict import click @@ -111,7 +111,7 @@ Everything below it will be ignored. """ -def editor_input(editor: str, initial_text: str): +def editor_input(editor: str, initial_text: str) -> str: """Lets user input text using an editor.""" tmp_path = _tmp_status_path() initial_text = (initial_text or "") + EDITOR_INPUT_INSTRUCTIONS @@ -127,7 +127,7 @@ def editor_input(editor: str, initial_text: str): return f.read().split(EDITOR_DIVIDER)[0].strip() -def delete_tmp_status_file(): +def delete_tmp_status_file() -> None: try: os.unlink(_tmp_status_path()) except FileNotFoundError: @@ -150,12 +150,12 @@ def _use_existing_tmp_file(tmp_path: str) -> bool: return False -def drop_empty_values(data: Dict) -> Dict: +def drop_empty_values(data: Dict[Any, Any]) -> Dict[Any, Any]: """Remove keys whose values are null""" return {k: v for k, v in data.items() if v is not None} -def urlencode_url(url): +def urlencode_url(url: str) -> str: parsed_url = urlparse(url) # unencode before encoding, to prevent double-urlencoding From e8dac36de32bbd5f788868fa10079f00159d9297 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Tue, 5 Dec 2023 08:51:09 +0100 Subject: [PATCH 22/59] Add `make bundle` for creating a pyz bundle --- .gitignore | 10 ++++++---- Makefile | 14 +++++++++++++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 82e262f..957a67b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,12 +6,14 @@ /.env /.envrc /.pytest_cache/ +/book /build/ +/bundle/ /dist/ /htmlcov/ -/tmp/ -/toot-*.tar.gz -debug.log /pyrightconfig.json +/tmp/ +/toot-*.pyz +/toot-*.tar.gz /venv/ -/book +debug.log diff --git a/Makefile b/Makefile index e560506..b6b26ad 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ coverage: clean : find . -name "*pyc" | xargs rm -rf $1 - rm -rf build dist MANIFEST htmlcov toot*.tar.gz + rm -rf build dist MANIFEST htmlcov bundle toot*.tar.gz toot*.pyz changelog: ./scripts/generate_changelog > CHANGELOG.md @@ -34,3 +34,15 @@ docs-serve: docs-deploy: docs rsync --archive --compress --delete --stats book/ bezdomni:web/toot + +bundle: + mkdir bundle + cp toot/__main__.py bundle + pip install . --target=bundle + rm -rf bundle/*.dist-info + find bundle/ -type d -name "__pycache__" -exec rm -rf {} + + python -m zipapp \ + --python "/usr/bin/env python3" \ + --output toot-`git describe`.pyz bundle \ + --compress + echo "Bundle created: toot-`git describe`.pyz" From b85daabb9dd35d3293d55747215246289b184b97 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Tue, 5 Dec 2023 08:52:12 +0100 Subject: [PATCH 23/59] Add missing package to discovery --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index aa56055..133a697 100644 --- a/setup.py +++ b/setup.py @@ -31,7 +31,7 @@ setup( 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', 'Programming Language :: Python :: 3', ], - packages=['toot', 'toot.tui', 'toot.tui.richtext', 'toot.utils'], + packages=['toot', 'toot.cli', 'toot.tui', 'toot.tui.richtext', 'toot.utils'], python_requires=">=3.7", install_requires=[ "click~=8.1", From 05dbd7bb57f06326ebc31a4859702251f0411cc2 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Tue, 5 Dec 2023 08:58:18 +0100 Subject: [PATCH 24/59] Fix bug in media upload --- toot/cli/post.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/toot/cli/post.py b/toot/cli/post.py index 80c6bdd..82fb751 100644 --- a/toot/cli/post.py +++ b/toot/cli/post.py @@ -242,7 +242,7 @@ def _upload_media(app, user, media, descriptions, thumbnails): for idx, file in enumerate(media): description = descriptions[idx].strip() if idx < len(descriptions) else None thumbnail = thumbnails[idx] if idx < len(thumbnails) else None - result = _do_upload(app, user, file, description, thumbnail) + result = _do_upload(app, user, file, description, thumbnail).json() uploaded_media.append(result) _wait_until_all_processed(app, user, uploaded_media) From b9aae37e7d722d626530873251be9ec72a681cc7 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Tue, 5 Dec 2023 08:58:31 +0100 Subject: [PATCH 25/59] Limit test files ...so that things from bundle are not picked up by mistake --- pytest.ini | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 pytest.ini diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..5ee6477 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +testpaths = tests From a8aeb32e188cbcf934dc4820cc6e68e87e227c61 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Tue, 5 Dec 2023 09:15:39 +0100 Subject: [PATCH 26/59] Fix typing not to break older python versions --- setup.py | 1 + toot/cli/base.py | 26 ++++++++++++++------------ 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/setup.py b/setup.py index 133a697..79e4833 100644 --- a/setup.py +++ b/setup.py @@ -59,6 +59,7 @@ setup( "pytest-xdist[psutil]", "setuptools", "vermin", + "typing-extensions", ], }, entry_points={ diff --git a/toot/cli/base.py b/toot/cli/base.py index 2cd86b9..13d7f51 100644 --- a/toot/cli/base.py +++ b/toot/cli/base.py @@ -2,11 +2,18 @@ import click import logging import os import sys +import typing as t 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 + +if t.TYPE_CHECKING: + import typing_extensions as te + P = te.ParamSpec("P") + +R = t.TypeVar("R") +T = t.TypeVar("T") PRIVACY_CHOICES = ["public", "unlisted", "private"] @@ -17,7 +24,7 @@ seconds" or any combination of above. Shorthand: "1d", "2h30m", "5m30s\"""" # Type alias for run commands -Run = Callable[..., Result] +Run = t.Callable[..., Result] def get_default_visibility() -> str: @@ -39,23 +46,18 @@ CONTEXT = dict( # Data object to add to Click context -class Context(NamedTuple): - app: Optional[App] - user: Optional[User] = None +class Context(t.NamedTuple): + app: t.Optional[App] + user: t.Optional[User] = None color: bool = False debug: bool = False quiet: bool = False -P = ParamSpec("P") -R = TypeVar("R") -T = TypeVar("T") - - -def pass_context(f: Callable[Concatenate[Context, P], R]) -> Callable[P, R]: +def pass_context(f: "t.Callable[te.Concatenate[Context, P], R]") -> "t.Callable[P, R]": """Pass `obj` from click context as first argument.""" @wraps(f) - def wrapped(*args: P.args, **kwargs: P.kwargs) -> R: + def wrapped(*args: "P.args", **kwargs: "P.kwargs") -> R: ctx = click.get_current_context() return f(ctx.obj, *args, **kwargs) From a653b557b4054c49654e835b9318a302e3693528 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Tue, 5 Dec 2023 09:25:02 +0100 Subject: [PATCH 27/59] Fix formatting --- toot/cli/post.py | 1 - toot/cli/read.py | 2 +- toot/cli/tui.py | 3 ++- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/toot/cli/post.py b/toot/cli/post.py index 82fb751..31dc733 100644 --- a/toot/cli/post.py +++ b/toot/cli/post.py @@ -204,7 +204,6 @@ def upload( click.echo(f"Preview URL: {media.preview_url}") - def _get_status_text(text, editor, media): isatty = sys.stdin.isatty() diff --git a/toot/cli/read.py b/toot/cli/read.py index c68eaea..f1bd7d5 100644 --- a/toot/cli/read.py +++ b/toot/cli/read.py @@ -7,7 +7,7 @@ from typing import Optional from toot import api from toot.entities import Instance, Status, from_dict, Account from toot.exceptions import ApiError, ConsoleError -from toot.output import print_account, print_instance, print_search_results, print_status, print_tag_list, print_timeline +from toot.output import print_account, print_instance, print_search_results, print_status, print_timeline from toot.cli.base import cli, json_option, pass_context, Context diff --git a/toot/cli/tui.py b/toot/cli/tui.py index bfc37d5..423b12c 100644 --- a/toot/cli/tui.py +++ b/toot/cli/tui.py @@ -1,8 +1,9 @@ -from typing import NamedTuple import click + from toot.cli.base import Context, cli, pass_context from toot.tui.app import TUI, TuiOptions + @cli.command() @click.option( "--relative-datetimes", From b539c933efafe6e5d314752595d1c973d6e43fc5 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Tue, 5 Dec 2023 09:59:40 +0100 Subject: [PATCH 28/59] Respect --no-color --- toot/cli/base.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/toot/cli/base.py b/toot/cli/base.py index 13d7f51..c1b0e58 100644 --- a/toot/cli/base.py +++ b/toot/cli/base.py @@ -78,10 +78,18 @@ json_option = click.option( @click.option("--quiet/--no-quiet", default=False, help="Don't print anything to stdout") @click.version_option(__version__, message="%(prog)s v%(version)s") @click.pass_context -def cli(ctx, color, debug, quiet, app=None, user=None): +def cli( + ctx: click.Context, + color: bool, + debug: bool, + quiet: bool, + app: t.Optional[App] = None, + user: t.Optional[User] = None, +): """Toot is a Mastodon CLI""" user, app = config.get_active_user_app() ctx.obj = Context(app, user, color, debug, quiet) + ctx.color = color if debug: logging.basicConfig(level=logging.DEBUG) From 78f994c0f1b6e5ab94c5b289406d0de510460c4b Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Tue, 5 Dec 2023 10:18:34 +0100 Subject: [PATCH 29/59] Make toot instance work with instance domain name --- toot/cli/read.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/toot/cli/read.py b/toot/cli/read.py index f1bd7d5..9677c4a 100644 --- a/toot/cli/read.py +++ b/toot/cli/read.py @@ -5,6 +5,7 @@ from itertools import chain from typing import Optional from toot import api +from toot.cli.validators import validate_instance from toot.entities import Instance, Status, from_dict, Account from toot.exceptions import ApiError, ConsoleError from toot.output import print_account, print_instance, print_search_results, print_status, print_timeline @@ -42,7 +43,7 @@ def whois(ctx: Context, account: str, json: bool): @cli.command() -@click.argument("instance_url", required=False) +@click.argument("instance_url", required=False, callback=validate_instance) @json_option @pass_context def instance(ctx: Context, instance_url: Optional[str], json: bool): From d91f3477a8f24f5f9595c9953ada552ff348b1ca Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Tue, 5 Dec 2023 10:41:54 +0100 Subject: [PATCH 30/59] Simplify main No need to handle this stuff here --- toot/__main__.py | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/toot/__main__.py b/toot/__main__.py index 403038e..fa3e807 100644 --- a/toot/__main__.py +++ b/toot/__main__.py @@ -1,15 +1,3 @@ -import sys from toot.cli import cli -from toot.exceptions import ConsoleError -from toot.output import print_err -from toot.settings import load_settings -try: - defaults = load_settings().get("commands", {}) - cli(default_map=defaults) -except ConsoleError as ex: - print_err(str(ex)) - sys.exit(1) -except KeyboardInterrupt: - print_err("Aborted") - sys.exit(1) +cli() From e89cc6d590690db8c6b77bcd52d36cc68476588f Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Tue, 5 Dec 2023 10:45:38 +0100 Subject: [PATCH 31/59] Load command defaults from settings --- toot/cli/base.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/toot/cli/base.py b/toot/cli/base.py index c1b0e58..b16cb34 100644 --- a/toot/cli/base.py +++ b/toot/cli/base.py @@ -7,6 +7,7 @@ import typing as t from click.testing import Result from functools import wraps from toot import App, User, config, __version__ +from toot.settings import load_settings if t.TYPE_CHECKING: import typing_extensions as te @@ -31,6 +32,13 @@ def get_default_visibility() -> str: return os.getenv("TOOT_POST_VISIBILITY", "public") +def get_default_map(): + settings = load_settings() + common = settings.get("common", {}) + commands = settings.get("commands", {}) + return {**common, **commands} + + # Tweak the Click context # https://click.palletsprojects.com/en/8.1.x/api/#context CONTEXT = dict( @@ -42,6 +50,8 @@ CONTEXT = dict( max_content_width=100, # Always show default values for options show_default=True, + # Load command defaults from settings + default_map=get_default_map(), ) From bbb5658781133a2e406f24a6ba99fd2fef77ba74 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Tue, 5 Dec 2023 11:39:22 +0100 Subject: [PATCH 32/59] Overhaul output to use click --- tests/integration/test_timelines.py | 2 +- tests/test_output.py | 26 -- toot/output.py | 407 ++++++++++++---------------- toot/utils/__init__.py | 4 +- 4 files changed, 176 insertions(+), 263 deletions(-) delete mode 100644 tests/test_output.py diff --git a/tests/integration/test_timelines.py b/tests/integration/test_timelines.py index 5f4da1e..70aa676 100644 --- a/tests/integration/test_timelines.py +++ b/tests/integration/test_timelines.py @@ -117,7 +117,7 @@ def test_empty_timeline(app, run_as): user = register_account(app) result = run_as(user, cli.timeline) assert result.exit_code == 0 - assert result.stdout.strip() == "─" * 100 + assert result.stdout.strip() == "─" * 80 def test_timeline_cant_combine_timelines(run): diff --git a/tests/test_output.py b/tests/test_output.py deleted file mode 100644 index cc31e5c..0000000 --- a/tests/test_output.py +++ /dev/null @@ -1,26 +0,0 @@ -from toot.output import colorize, strip_tags, STYLES - -reset = STYLES["reset"] -red = STYLES["red"] -green = STYLES["green"] -bold = STYLES["bold"] - - -def test_colorize(): - assert colorize("foo") == "foo" - assert colorize("foo") == f"{red}foo{reset}{reset}" - assert colorize("foo bar baz") == f"foo {red}bar{reset} baz{reset}" - assert colorize("foo bar baz") == f"foo {red}{bold}bar{reset} baz{reset}" - assert colorize("foo bar baz") == f"foo {red}{bold}bar{reset}{bold} baz{reset}" - assert colorize("foo bar baz") == f"foo {red}{bold}bar{reset} baz{reset}" - assert colorize("foobarbaz") == f"{red}foo{bold}bar{reset}{red}baz{reset}{reset}" - - -def test_strip_tags(): - assert strip_tags("foo") == "foo" - assert strip_tags("foo") == "foo" - assert strip_tags("foo bar baz") == "foo bar baz" - assert strip_tags("foo bar baz") == "foo bar baz" - assert strip_tags("foo bar baz") == "foo bar baz" - assert strip_tags("foo bar baz") == "foo bar baz" - assert strip_tags("foobarbaz") == "foobarbaz" diff --git a/toot/output.py b/toot/output.py index d526539..fc4b7dd 100644 --- a/toot/output.py +++ b/toot/output.py @@ -1,227 +1,103 @@ -import os +import click import re -import sys import textwrap -from functools import lru_cache -from toot import settings -from toot.utils import get_text, html_to_paragraphs from toot.entities import Account, Instance, Notification, Poll, Status +from toot.utils import get_text, html_to_paragraphs from toot.wcstring import wc_wrap -from typing import Iterable, List +from typing import Any, Generator, Iterable, List from wcwidth import wcswidth -STYLES = { - 'reset': '\033[0m', - 'bold': '\033[1m', - 'dim': '\033[2m', - 'italic': '\033[3m', - 'underline': '\033[4m', - 'red': '\033[91m', - 'green': '\033[92m', - 'yellow': '\033[93m', - 'blue': '\033[94m', - 'magenta': '\033[95m', - 'cyan': '\033[96m', -} - -STYLE_TAG_PATTERN = re.compile(r""" - (? # literal -""", re.X) +def print_instance(instance: Instance, width: int = 80): + click.echo(instance_to_text(instance, width)) -def colorize(message): - """ - Replaces style tags in `message` with ANSI escape codes. - - Markup is inspired by HTML, but you can use multiple words pre tag, e.g.: - - alert! a thing happened - - Empty closing tag will reset all styes: - - alert! a thing happened - - Styles can be nested: - - red red and underline red - """ - - def _codes(styles): - for style in styles: - yield STYLES.get(style, "") - - def _generator(message): - # A list is used instead of a set because we want to keep style order - # This allows nesting colors, e.g. "foobarbaz" - position = 0 - active_styles = [] - - for match in re.finditer(STYLE_TAG_PATTERN, message): - is_closing = bool(match.group(1)) - styles = match.group(2).strip().split() - - start, end = match.span() - # Replace backslash for escaped < - yield message[position:start].replace("\\<", "<") - - if is_closing: - yield STYLES["reset"] - - # Empty closing tag resets all styles - if styles == []: - active_styles = [] - else: - active_styles = [s for s in active_styles if s not in styles] - yield from _codes(active_styles) - else: - active_styles = active_styles + styles - yield from _codes(styles) - - position = end - - if position == 0: - # Nothing matched, yield the original string - yield message - else: - # Yield the remaining fragment - yield message[position:] - # Reset styles at the end to prevent leaking - yield STYLES["reset"] - - return "".join(_generator(message)) +def instance_to_text(instance: Instance, width: int) -> str: + return "\n".join(instance_lines(instance, width)) -def strip_tags(message): - return re.sub(STYLE_TAG_PATTERN, "", message) - - -@lru_cache(maxsize=None) -def use_ansi_color(): - """Returns True if ANSI color codes should be used.""" - - # Windows doesn't support color unless ansicon is installed - # See: http://adoxa.altervista.org/ansicon/ - if sys.platform == 'win32' and 'ANSICON' not in os.environ: - return False - - # Don't show color if stdout is not a tty, e.g. if output is piped on - if not sys.stdout.isatty(): - return False - - # Don't show color if explicitly specified in options - if "--no-color" in sys.argv: - return False - - # Check in settings - color = settings.get_setting("common.color", bool) - if color is not None: - return color - - # Use color by default - return True - - -def print_out(*args, **kwargs): - if not settings.get_quiet(): - args = [colorize(a) if use_ansi_color() else strip_tags(a) for a in args] - print(*args, **kwargs) - - -def print_err(*args, **kwargs): - args = [f"{a}" for a in args] - args = [colorize(a) if use_ansi_color() else strip_tags(a) for a in args] - print(*args, file=sys.stderr, **kwargs) - - -def print_instance(instance: Instance): - print_out(f"{instance.title}") - print_out(f"{instance.uri}") - print_out(f"running Mastodon {instance.version}") - print_out() +def instance_lines(instance: Instance, width: int) -> Generator[str, None, None]: + yield f"{green(instance.title)}" + yield f"{blue(instance.uri)}" + yield f"running Mastodon {instance.version}" + yield "" if instance.description: for paragraph in re.split(r"[\r\n]+", instance.description.strip()): paragraph = get_text(paragraph) - print_out(textwrap.fill(paragraph, width=80)) - print_out() + yield textwrap.fill(paragraph, width=width) + yield "" if instance.rules: - print_out("Rules:") + yield "Rules:" for ordinal, rule in enumerate(instance.rules): ordinal = f"{ordinal + 1}." - lines = textwrap.wrap(rule.text, 80 - len(ordinal)) + lines = textwrap.wrap(rule.text, width - len(ordinal)) first = True for line in lines: if first: - print_out(f"{ordinal} {line}") + yield f"{ordinal} {line}" first = False else: - print_out(f"{' ' * len(ordinal)} {line}") - print_out() + yield f"{' ' * len(ordinal)} {line}" + yield "" contact = instance.contact_account if contact: - print_out(f"Contact: {contact.display_name} @{contact.acct}") + yield f"Contact: {contact.display_name} @{contact.acct}" -def print_account(account: Account): - print_out(f"@{account.acct} {account.display_name}") +def print_account(account: Account, width: int = 80) -> None: + click.echo(account_to_text(account, width)) + + +def account_to_text(account: Account, width: int) -> str: + return "\n".join(account_lines(account, width)) + + +def account_lines(account: Account, width: int) -> Generator[str, None, None]: + acct = f"@{account.acct}" + since = account.created_at.strftime("%Y-%m-%d") + + yield f"{green(acct)} {account.display_name}" if account.note: - print_out("") - print_html(account.note) + yield "" + yield from html_lines(account.note, width) - since = account.created_at.strftime('%Y-%m-%d') - - print_out("") - print_out(f"ID: {account.id}") - print_out(f"Since: {since}") - print_out("") - print_out(f"Followers: {account.followers_count}") - print_out(f"Following: {account.following_count}") - print_out(f"Statuses: {account.statuses_count}") + yield "" + yield f"ID: {green(account.id)}" + yield f"Since: {green(since)}" + yield "" + yield f"Followers: {yellow(account.followers_count)}" + yield f"Following: {yellow(account.following_count)}" + yield f"Statuses: {yellow(account.statuses_count)}" if account.fields: for field in account.fields: name = field.name.title() - print_out(f'\n{name}:') - print_html(field.value) + yield f'\n{yellow(name)}:' + yield from html_lines(field.value, width) if field.verified_at: - print_out("✓ Verified") + yield green("✓ Verified") - print_out("") - print_out(account.url) - - -HASHTAG_PATTERN = re.compile(r'(?\\1', line) + yield "" + yield account.url def print_acct_list(accounts): for account in accounts: - print_out(f"* @{account['acct']} {account['display_name']}") - - -def print_user_list(users): - for user in users: - print_out(f"* {user}") + acct = green(f"@{account['acct']}") + click.echo(f"* {acct} {account['display_name']}") def print_tag_list(tags): if tags: for tag in tags: - print_out(f"* #{tag['name']}\t{tag['url']}") + click.echo(f"* {format_tag_name(tag)}\t{tag['url']}") else: - print_out("You're not following any hashtags.") + click.echo("You're not following any hashtags.") def print_lists(lists): @@ -234,20 +110,17 @@ def print_table(headers: List[str], data: List[List[str]]): widths = [[len(cell) for cell in row] for row in data + [headers]] widths = [max(width) for width in zip(*widths)] - def style(string, tag): - return f"<{tag}>{string}" if tag else string - - def print_row(row, tag=None): + def print_row(row): for idx, cell in enumerate(row): width = widths[idx] - print_out(style(cell.ljust(width), tag), end="") - print_out(" ", end="") - print_out() + click.echo(cell.ljust(width), nl=False) + click.echo(" ", nl=False) + click.echo() underlines = ["-" * width for width in widths] - print_row(headers, "bold") - print_row(underlines, "dim") + print_row(headers) + print_row(underlines) for row in data: print_row(row) @@ -255,33 +128,40 @@ def print_table(headers: List[str], data: List[List[str]]): def print_list_accounts(accounts): if accounts: - print_out("Accounts in list:\n") + click.echo("Accounts in list:\n") print_acct_list(accounts) else: - print_out("This list has no accounts.") + click.echo("This list has no accounts.") def print_search_results(results): - accounts = results['accounts'] - hashtags = results['hashtags'] + accounts = results["accounts"] + hashtags = results["hashtags"] if accounts: - print_out("\nAccounts:") + click.echo("\nAccounts:") print_acct_list(accounts) if hashtags: - print_out("\nHashtags:") - print_out(", ".join([f"#{t['name']}" for t in hashtags])) + click.echo("\nHashtags:") + click.echo(", ".join([format_tag_name(tag) for tag in hashtags])) if not accounts and not hashtags: - print_out("Nothing found") + click.echo("Nothing found") -def print_status(status: Status, width: int = 80): +def print_status(status: Status, width: int = 80) -> None: + click.echo(status_to_text(status, width)) + + +def status_to_text(status: Status, width: int) -> str: + return "\n".join(status_lines(status, width)) + + +def status_lines(status: Status, width: int = 80) -> Generator[str, None, None]: status_id = status.id in_reply_to_id = status.in_reply_to_id reblogged_by = status.account if status.reblog else None - status = status.original time = status.created_at.strftime('%Y-%m-%d %H:%M %Z') @@ -289,61 +169,60 @@ def print_status(status: Status, width: int = 80): spacing = width - wcswidth(username) - wcswidth(time) - 2 display_name = status.account.display_name + if display_name: + author = f"{green(display_name)} {blue(username)}" spacing -= wcswidth(display_name) + 1 + else: + author = blue(username) - print_out( - f"{display_name}" if display_name else "", - f"{username}", - " " * spacing, - f"{time}", - ) + spaces = " " * spacing + yield f"{author} {spaces} {yellow(time)}" - print_out("") - print_html(status.content, width) + yield "" + yield from html_lines(status.content, width) if status.media_attachments: - print_out("\nMedia:") + yield "" + yield "Media:" for attachment in status.media_attachments: url = attachment.url for line in wc_wrap(url, width): - print_out(line) + yield line if status.poll: - print_poll(status.poll) + yield from poll_lines(status.poll) - print_out() + reblogged_by_acct = f"@{reblogged_by.acct}" if reblogged_by else None + yield "" - print_out( - f"ID {status_id} ", - f"↲ In reply to {in_reply_to_id} " if in_reply_to_id else "", - f"↻ @{reblogged_by.acct} boosted " if reblogged_by else "", - ) + reply = f"↲ In reply to {yellow(in_reply_to_id)} " if in_reply_to_id else "" + boost = f"↻ {blue(reblogged_by_acct)} boosted " if reblogged_by else "" + yield f"ID {yellow(status_id)} {reply} {boost}" -def print_html(text, width=80): +def html_lines(html: str, width: int) -> Generator[str, None, None]: first = True - for paragraph in html_to_paragraphs(text): + for paragraph in html_to_paragraphs(html): if not first: - print_out("") + yield "" for line in paragraph: for subline in wc_wrap(line, width): - print_out(highlight_hashtags(subline)) + yield subline first = False -def print_poll(poll: Poll): - print_out() +def poll_lines(poll: Poll) -> Generator[str, None, None]: for idx, option in enumerate(poll.options): perc = (round(100 * option.votes_count / poll.votes_count) if poll.votes_count and option.votes_count is not None else 0) if poll.voted and poll.own_votes and idx in poll.own_votes: - voted_for = " " + voted_for = yellow(" ✓") else: voted_for = "" - print_out(f'{option.title} - {perc}% {voted_for}') + yield f"{option.title} - {perc}% {voted_for}" poll_footer = f'Poll · {poll.votes_count} votes' @@ -354,15 +233,15 @@ def print_poll(poll: Poll): expires_at = poll.expires_at.strftime("%Y-%m-%d %H:%M") poll_footer += f" · Closes on {expires_at}" - print_out() - print_out(poll_footer) + yield "" + yield poll_footer -def print_timeline(items: Iterable[Status], width=100): - print_out("─" * width) +def print_timeline(items: Iterable[Status], width=80): + click.echo("─" * width) for item in items: print_status(item, width) - print_out("─" * width) + click.echo("─" * width) notification_msgs = { @@ -373,19 +252,79 @@ notification_msgs = { } -def print_notification(notification: Notification, width=100): - account = f"{notification.account.display_name} @{notification.account.acct}" - msg = notification_msgs.get(notification.type) - if msg is None: - return - - print_out("─" * width) - print_out(msg.format(account=account)) +def print_notification(notification: Notification, width=80): + print_notification_header(notification) if notification.status: + click.echo("-" * width) print_status(notification.status, width) -def print_notifications(notifications: List[Notification], width=100): +def print_notifications(notifications: List[Notification], width=80): for notification in notifications: + click.echo("─" * width) print_notification(notification) - print_out("─" * width) + click.echo("─" * width) + + +def print_notification_header(notification: Notification): + account_name = format_account_name(notification.account) + + if (notification.type == "follow"): + click.echo(f"{account_name} now follows you") + elif (notification.type == "mention"): + click.echo(f"{account_name} mentioned you") + elif (notification.type == "reblog"): + click.echo(f"{account_name} reblogged your status") + elif (notification.type == "favourite"): + click.echo(f"{account_name} favourited your status") + elif (notification.type == "update"): + click.echo(f"{account_name} edited a post") + else: + click.secho(f"Unknown notification type: '{notification.type}'", err=True, fg="yellow") + click.secho("Please report an issue to toot.", err=True, fg="yellow") + + +notification_msgs = { + "follow": "{account} now follows you", + "mention": "{account} mentioned you in", + "reblog": "{account} reblogged your status", + "favourite": "{account} favourited your status", +} + + +def format_tag_name(tag): + return green(f"#{tag['name']}") + + +def format_account_name(account: Account) -> str: + acct = blue(f"@{account.acct}") + if account.display_name: + return f"{green(account.display_name)} {acct}" + else: + return acct + + +# Shorthand functions for coloring output + +def blue(text: Any) -> str: + return click.style(text, fg="blue") + + +def bold(text: Any) -> str: + return click.style(text, bold=True) + + +def cyan(text: Any) -> str: + return click.style(text, fg="cyan") + + +def dim(text: Any) -> str: + return click.style(text, dim=True) + + +def green(text: Any) -> str: + return click.style(text, fg="green") + + +def yellow(text: Any) -> str: + return click.style(text, fg="yellow") diff --git a/toot/utils/__init__.py b/toot/utils/__init__.py index d9ffbb9..7bc5c77 100644 --- a/toot/utils/__init__.py +++ b/toot/utils/__init__.py @@ -7,7 +7,7 @@ import unicodedata import warnings from bs4 import BeautifulSoup -from typing import Any, Dict +from typing import Any, Dict, List import click @@ -40,7 +40,7 @@ def get_text(html): return unicodedata.normalize("NFKC", text) -def html_to_paragraphs(html): +def html_to_paragraphs(html: str) -> List[List[str]]: """Attempt to convert html to plain text while keeping line breaks. Returns a list of paragraphs, each being a list of lines. """ From ac7964a7b451a438f73e87a234dfadfd3fe65fe4 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Tue, 5 Dec 2023 12:00:45 +0100 Subject: [PATCH 33/59] Use cached fn to get settings --- toot/cli/base.py | 4 ++-- toot/settings.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/toot/cli/base.py b/toot/cli/base.py index b16cb34..630c8a5 100644 --- a/toot/cli/base.py +++ b/toot/cli/base.py @@ -7,7 +7,7 @@ import typing as t from click.testing import Result from functools import wraps from toot import App, User, config, __version__ -from toot.settings import load_settings +from toot.settings import get_settings if t.TYPE_CHECKING: import typing_extensions as te @@ -33,7 +33,7 @@ def get_default_visibility() -> str: def get_default_map(): - settings = load_settings() + settings = get_settings() common = settings.get("common", {}) commands = settings.get("commands", {}) return {**common, **commands} diff --git a/toot/settings.py b/toot/settings.py index 90d4443..3862c8f 100644 --- a/toot/settings.py +++ b/toot/settings.py @@ -17,7 +17,7 @@ def get_settings_path(): return join(get_config_dir(), TOOT_SETTINGS_FILE_NAME) -def load_settings() -> dict: +def _load_settings() -> dict: # Used for testing without config file if DISABLE_SETTINGS: return {} @@ -33,7 +33,7 @@ def load_settings() -> dict: @lru_cache(maxsize=None) def get_settings(): - return load_settings() + return _load_settings() T = TypeVar("T") From bf5eb9e7f8d189f9da19f6f1030e25785e1dd9a5 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Thu, 7 Dec 2023 10:03:33 +0100 Subject: [PATCH 34/59] Add --width option --- toot/cli/base.py | 5 +++-- toot/output.py | 43 +++++++++++++++++++++++++++++++++---------- 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/toot/cli/base.py b/toot/cli/base.py index 630c8a5..83d235c 100644 --- a/toot/cli/base.py +++ b/toot/cli/base.py @@ -46,8 +46,6 @@ CONTEXT = dict( auto_envvar_prefix="TOOT", # Add shorthand -h for invoking help help_option_names=["-h", "--help"], - # Give help some more room (default is 80) - max_content_width=100, # Always show default values for options show_default=True, # Load command defaults from settings @@ -83,6 +81,7 @@ json_option = click.option( @click.group(context_settings=CONTEXT) +@click.option("-w", "--max-width", type=int, default=80, help="Maximum width for content rendered by toot") @click.option("--debug/--no-debug", default=False, help="Log debug info to stderr") @click.option("--color/--no-color", default=sys.stdout.isatty(), help="Use ANSI color in output") @click.option("--quiet/--no-quiet", default=False, help="Don't print anything to stdout") @@ -90,6 +89,7 @@ json_option = click.option( @click.pass_context def cli( ctx: click.Context, + max_width: int, color: bool, debug: bool, quiet: bool, @@ -100,6 +100,7 @@ def cli( user, app = config.get_active_user_app() ctx.obj = Context(app, user, color, debug, quiet) ctx.color = color + ctx.max_content_width = max_width if debug: logging.basicConfig(level=logging.DEBUG) diff --git a/toot/output.py b/toot/output.py index fc4b7dd..9be2231 100644 --- a/toot/output.py +++ b/toot/output.py @@ -1,6 +1,7 @@ import click import re import textwrap +import shutil from toot.entities import Account, Instance, Notification, Poll, Status from toot.utils import get_text, html_to_paragraphs @@ -9,7 +10,23 @@ from typing import Any, Generator, Iterable, List from wcwidth import wcswidth -def print_instance(instance: Instance, width: int = 80): +DEFAULT_WIDTH = 80 + + +def get_max_width() -> int: + return click.get_current_context().max_content_width or DEFAULT_WIDTH + + +def get_terminal_width() -> int: + return shutil.get_terminal_size().columns + + +def get_width() -> int: + return min(get_terminal_width(), get_max_width()) + + +def print_instance(instance: Instance): + width = get_width() click.echo(instance_to_text(instance, width)) @@ -48,7 +65,8 @@ def instance_lines(instance: Instance, width: int) -> Generator[str, None, None] yield f"Contact: {contact.display_name} @{contact.acct}" -def print_account(account: Account, width: int = 80) -> None: +def print_account(account: Account) -> None: + width = get_width() click.echo(account_to_text(account, width)) @@ -150,15 +168,17 @@ def print_search_results(results): click.echo("Nothing found") -def print_status(status: Status, width: int = 80) -> None: +def print_status(status: Status) -> None: + width = get_width() click.echo(status_to_text(status, width)) def status_to_text(status: Status, width: int) -> str: - return "\n".join(status_lines(status, width)) + return "\n".join(status_lines(status)) -def status_lines(status: Status, width: int = 80) -> Generator[str, None, None]: +def status_lines(status: Status) -> Generator[str, None, None]: + width = get_width() status_id = status.id in_reply_to_id = status.in_reply_to_id reblogged_by = status.account if status.reblog else None @@ -237,10 +257,11 @@ def poll_lines(poll: Poll) -> Generator[str, None, None]: yield poll_footer -def print_timeline(items: Iterable[Status], width=80): +def print_timeline(items: Iterable[Status]): + width = get_width() click.echo("─" * width) for item in items: - print_status(item, width) + print_status(item) click.echo("─" * width) @@ -252,14 +273,16 @@ notification_msgs = { } -def print_notification(notification: Notification, width=80): +def print_notification(notification: Notification): + width = get_width() print_notification_header(notification) if notification.status: click.echo("-" * width) - print_status(notification.status, width) + print_status(notification.status) -def print_notifications(notifications: List[Notification], width=80): +def print_notifications(notifications: List[Notification]): + width = get_width() for notification in notifications: click.echo("─" * width) print_notification(notification) From a4cf678b15e29a9b3a908479d07980de81bdf81d Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Thu, 7 Dec 2023 10:06:39 +0100 Subject: [PATCH 35/59] Extract print_divider --- toot/output.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/toot/output.py b/toot/output.py index 9be2231..8ac5390 100644 --- a/toot/output.py +++ b/toot/output.py @@ -258,11 +258,10 @@ def poll_lines(poll: Poll) -> Generator[str, None, None]: def print_timeline(items: Iterable[Status]): - width = get_width() - click.echo("─" * width) + print_divider() for item in items: print_status(item) - click.echo("─" * width) + print_divider() notification_msgs = { @@ -274,19 +273,17 @@ notification_msgs = { def print_notification(notification: Notification): - width = get_width() print_notification_header(notification) if notification.status: - click.echo("-" * width) + print_divider(char="-") print_status(notification.status) def print_notifications(notifications: List[Notification]): - width = get_width() for notification in notifications: - click.echo("─" * width) + print_divider() print_notification(notification) - click.echo("─" * width) + print_divider() def print_notification_header(notification: Notification): @@ -315,6 +312,10 @@ notification_msgs = { } +def print_divider(char: str = "─"): + click.echo(char * get_width()) + + def format_tag_name(tag): return green(f"#{tag['name']}") From 8e7a90e8daadac65d8a0a9ab54d13f3d2a85dc7d Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Thu, 7 Dec 2023 10:08:10 +0100 Subject: [PATCH 36/59] Remove unused code --- tests/integration/conftest.py | 6 ------ toot/output.py | 16 ---------------- 2 files changed, 22 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 528be6a..c0b14ee 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -151,12 +151,6 @@ def run_anon(runner): # Utils # ------------------------------------------------------------------------------ -strip_ansi_pattern = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") - - -def strip_ansi(string): - return strip_ansi_pattern.sub("", string).strip() - def posted_status_id(out): pattern = re.compile(r"Toot posted: http://([^/]+)/([^/]+)/(.+)") diff --git a/toot/output.py b/toot/output.py index 8ac5390..e08bec0 100644 --- a/toot/output.py +++ b/toot/output.py @@ -264,14 +264,6 @@ def print_timeline(items: Iterable[Status]): print_divider() -notification_msgs = { - "follow": "{account} now follows you", - "mention": "{account} mentioned you in", - "reblog": "{account} reblogged your status", - "favourite": "{account} favourited your status", -} - - def print_notification(notification: Notification): print_notification_header(notification) if notification.status: @@ -304,14 +296,6 @@ def print_notification_header(notification: Notification): click.secho("Please report an issue to toot.", err=True, fg="yellow") -notification_msgs = { - "follow": "{account} now follows you", - "mention": "{account} mentioned you in", - "reblog": "{account} reblogged your status", - "favourite": "{account} favourited your status", -} - - def print_divider(char: str = "─"): click.echo(char * get_width()) From 11cfa5834b877bcd230ced25fae89472f5425437 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Thu, 7 Dec 2023 10:23:17 +0100 Subject: [PATCH 37/59] Remove default from environment variable Click already does that for us. --- toot/cli/post.py | 8 ++++---- toot/cli/statuses.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/toot/cli/post.py b/toot/cli/post.py index 31dc733..138faf4 100644 --- a/toot/cli/post.py +++ b/toot/cli/post.py @@ -1,14 +1,14 @@ -import sys -from time import sleep, time import click import os +import sys from datetime import datetime, timedelta, timezone +from time import sleep, time from typing import BinaryIO, 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.base import DURATION_EXAMPLES, VISIBILITY_CHOICES from toot.cli.validators import validate_duration, validate_language from toot.entities import MediaAttachment, from_dict from toot.utils import EOF_KEY, delete_tmp_status_file, editor_input, multiline_input @@ -40,7 +40,7 @@ from toot.utils.datetime import parse_datetime "--visibility", "-v", help="Post visibility", type=click.Choice(VISIBILITY_CHOICES), - default=get_default_visibility(), + default="public", ) @click.option( "--sensitive", "-s", diff --git a/toot/cli/statuses.py b/toot/cli/statuses.py index 1cc755b..9e3ecee 100644 --- a/toot/cli/statuses.py +++ b/toot/cli/statuses.py @@ -2,7 +2,7 @@ import click from toot import api from toot.cli.base import cli, json_option, Context, pass_context -from toot.cli.base import VISIBILITY_CHOICES, get_default_visibility +from toot.cli.base import VISIBILITY_CHOICES from toot.output import print_table @@ -51,7 +51,7 @@ def unfavourite(ctx: Context, status_id: str, json: bool): "--visibility", "-v", help="Post visibility", type=click.Choice(VISIBILITY_CHOICES), - default=get_default_visibility(), + default="public", ) @json_option @pass_context From 92dbdf5c3ec52ae63c466036ff7e939c28b68462 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Thu, 7 Dec 2023 18:24:06 +0100 Subject: [PATCH 38/59] Move docs server to port 8000 By default it's on 3000 which is the same as mastodon. --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index b6b26ad..7500d13 100644 --- a/Makefile +++ b/Makefile @@ -30,7 +30,7 @@ docs: changelog mdbook build docs-serve: - mdbook serve + mdbook serve --port 8000 docs-deploy: docs rsync --archive --compress --delete --stats book/ bezdomni:web/toot From ac77ea75cecf4471fd2084f10887fab7b0295303 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Thu, 7 Dec 2023 19:11:12 +0100 Subject: [PATCH 39/59] Remove unused code --- toot/settings.py | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/toot/settings.py b/toot/settings.py index 3862c8f..4da3322 100644 --- a/toot/settings.py +++ b/toot/settings.py @@ -1,6 +1,3 @@ -import os -import sys - from functools import lru_cache from os.path import exists, join from tomlkit import parse @@ -62,26 +59,3 @@ def _get_setting(dct, keys, type: Type, default=None): return _get_setting(dct[key], keys[1:], type, default) return default - - -def get_debug() -> bool: - if "--debug" in sys.argv: - return True - - return get_setting("common.debug", bool, False) - - -def get_debug_file() -> Optional[str]: - from_env = os.getenv("TOOT_LOG_FILE") - if from_env: - return from_env - - return get_setting("common.debug_file", str) - - -@lru_cache(maxsize=None) -def get_quiet(): - if "--quiet" in sys.argv: - return True - - return get_setting("common.quiet", str, False) From bbf67c6736bc096e50a3e55df934a27c803166aa Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Thu, 7 Dec 2023 19:11:59 +0100 Subject: [PATCH 40/59] Pass tui options through cli options --- toot/cli/base.py | 11 +++++++++++ toot/cli/tui.py | 37 ++++++++++++++++++++++++++++++++----- toot/cli/validators.py | 15 +++++++++++++++ toot/output.py | 4 ++++ toot/tui/app.py | 37 +++++++++++++++++-------------------- toot/tui/timeline.py | 5 ++--- 6 files changed, 81 insertions(+), 28 deletions(-) diff --git a/toot/cli/base.py b/toot/cli/base.py index 83d235c..4a7362e 100644 --- a/toot/cli/base.py +++ b/toot/cli/base.py @@ -20,6 +20,17 @@ T = t.TypeVar("T") PRIVACY_CHOICES = ["public", "unlisted", "private"] VISIBILITY_CHOICES = ["public", "unlisted", "private", "direct"] +TUI_COLORS = { + "1": 1, + "16": 16, + "88": 88, + "256": 256, + "16777216": 16777216, + "24bit": 16777216, +} +TUI_COLORS_CHOICES = list(TUI_COLORS.keys()) +TUI_COLORS_VALUES = list(TUI_COLORS.values()) + DURATION_EXAMPLES = """e.g. "1 day", "2 hours 30 minutes", "5 minutes 30 seconds" or any combination of above. Shorthand: "1d", "2h30m", "5m30s\"""" diff --git a/toot/cli/tui.py b/toot/cli/tui.py index 423b12c..a0c0f0f 100644 --- a/toot/cli/tui.py +++ b/toot/cli/tui.py @@ -1,17 +1,44 @@ import click -from toot.cli.base import Context, cli, pass_context +from typing import Optional +from toot.cli.base import TUI_COLORS, Context, cli, pass_context +from toot.cli.validators import validate_tui_colors from toot.tui.app import TUI, TuiOptions +COLOR_OPTIONS = ", ".join(TUI_COLORS.keys()) + @cli.command() @click.option( - "--relative-datetimes", + "-r", "--relative-datetimes", is_flag=True, help="Show relative datetimes in status list" ) +@click.option( + "-m", "--media-viewer", + help="Program to invoke with media URLs to display the media files, such as 'feh'" +) +@click.option( + "-c", "--colors", + callback=validate_tui_colors, + help=f"""Number of colors to use, one of {COLOR_OPTIONS}, defaults to 16 if + using --color, and 1 if using --no-color.""" +) @pass_context -def tui(ctx: Context, relative_datetimes: bool): +def tui( + ctx: Context, + colors: Optional[int], + media_viewer: Optional[str], + relative_datetimes: bool, +): """Launches the toot terminal user interface""" - options = TuiOptions(relative_datetimes, ctx.color) - TUI.create(ctx.app, ctx.user, options).run() + if colors is None: + colors = 16 if ctx.color else 1 + + options = TuiOptions( + colors=colors, + media_viewer=media_viewer, + relative_datetimes=relative_datetimes, + ) + tui = TUI.create(ctx.app, ctx.user, options) + tui.run() diff --git a/toot/cli/validators.py b/toot/cli/validators.py index cfdd097..819fdf9 100644 --- a/toot/cli/validators.py +++ b/toot/cli/validators.py @@ -4,6 +4,8 @@ import re from click import Context from typing import Optional +from toot.cli.base import TUI_COLORS + def validate_language(ctx: Context, param: str, value: Optional[str]): if value is None: @@ -58,3 +60,16 @@ def validate_instance(ctx: click.Context, param: str, value: Optional[str]): value = value.rstrip("/") return value if value.startswith("http") else f"https://{value}" + + +def validate_tui_colors(ctx, param, value) -> Optional[int]: + if value is None: + return None + + if value in TUI_COLORS.values(): + return value + + if value in TUI_COLORS.keys(): + return TUI_COLORS[value] + + raise click.BadParameter(f"Invalid value: {value}. Expected one of: {', '.join(TUI_COLORS)}") diff --git a/toot/output.py b/toot/output.py index e08bec0..02f1083 100644 --- a/toot/output.py +++ b/toot/output.py @@ -25,6 +25,10 @@ def get_width() -> int: return min(get_terminal_width(), get_max_width()) +def print_warning(text: str): + click.secho(f"Warning: {text}", fg="yellow", err=True) + + def print_instance(instance: Instance): width = get_width() click.echo(instance_to_text(instance, width)) diff --git a/toot/tui/app.py b/toot/tui/app.py index 77cbaaf..bc712cd 100644 --- a/toot/tui/app.py +++ b/toot/tui/app.py @@ -3,7 +3,7 @@ import subprocess import urwid from concurrent.futures import ThreadPoolExecutor -from typing import NamedTuple +from typing import NamedTuple, Optional from toot import api, config, __version__, settings from toot import App, User @@ -28,8 +28,9 @@ DEFAULT_MAX_TOOT_CHARS = 500 class TuiOptions(NamedTuple): + colors: int + media_viewer: Optional[str] relative_datetimes: bool - color: bool class Header(urwid.WidgetWrap): @@ -89,7 +90,9 @@ class TUI(urwid.Frame): @staticmethod def create(app: App, user: User, args: TuiOptions): """Factory method, sets up TUI and an event loop.""" - screen = TUI.create_screen(args) + screen = urwid.raw_display.Screen() + screen.set_terminal_properties(args.colors) + tui = TUI(app, user, screen, args) palette = PALETTE.copy() @@ -108,23 +111,11 @@ class TUI(urwid.Frame): return tui - @staticmethod - def create_screen(args: TuiOptions): - screen = urwid.raw_display.Screen() - - # Determine how many colors to use - default_colors = 16 if args.color else 1 - colors = settings.get_setting("tui.colors", int, default_colors) - logger.debug(f"Setting colors to {colors}") - screen.set_terminal_properties(colors) - - return screen - - def __init__(self, app, user, screen, args: TuiOptions): + def __init__(self, app, user, screen, options: TuiOptions): self.app = app self.user = user - self.args = args self.config = config.load_config() + self.options = options self.loop = None # late init, set in `create` self.screen = screen @@ -146,7 +137,6 @@ class TUI(urwid.Frame): self.can_translate = False self.account = None self.followed_accounts = [] - self.media_viewer = settings.get_setting("tui.media_viewer", str) super().__init__(self.body, header=self.header, footer=self.footer) @@ -510,8 +500,15 @@ class TUI(urwid.Frame): if not urls: return - if self.media_viewer: - subprocess.run([self.media_viewer] + urls) + media_viewer = self.options.media_viewer + if media_viewer: + try: + subprocess.run([media_viewer] + urls) + except FileNotFoundError: + self.footer.set_error_message(f"Media viewer not found: '{media_viewer}'") + except Exception as ex: + self.exception = ex + self.footer.set_error_message("Failed invoking media viewer. Press X to see exception.") else: self.footer.set_error_message("Media viewer not configured") diff --git a/toot/tui/timeline.py b/toot/tui/timeline.py index 60dfeb0..63eb4b3 100644 --- a/toot/tui/timeline.py +++ b/toot/tui/timeline.py @@ -79,7 +79,7 @@ class Timeline(urwid.Columns): return urwid.ListBox(walker) def build_list_item(self, status): - item = StatusListItem(status, self.tui.args.relative_datetimes) + item = StatusListItem(status, self.tui.options.relative_datetimes) urwid.connect_signal(item, "click", lambda *args: self.tui.show_context_menu(status)) return urwid.AttrMap(item, None, focus_map={ @@ -95,7 +95,7 @@ class Timeline(urwid.Columns): return None poll = status.original.data.get("poll") - show_media = status.original.data["media_attachments"] and self.tui.media_viewer + show_media = status.original.data["media_attachments"] and self.tui.options.media_viewer options = [ "[A]ccount" if not status.is_mine else "", @@ -107,7 +107,6 @@ class Timeline(urwid.Columns): "[T]hread" if not self.is_thread else "", "L[i]nks", "[M]edia" if show_media else "", - self.tui.media_viewer, "[R]eply", "[P]oll" if poll and not poll["expired"] else "", "So[u]rce", From c7b5669c789b9be92370b4c937686579bce0953e Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Thu, 7 Dec 2023 19:21:41 +0100 Subject: [PATCH 41/59] Add docs for shell completion --- CHANGELOG.md | 8 ++++++++ changelog.yaml | 5 +++-- docs/SUMMARY.md | 1 + docs/changelog.md | 8 ++++++++ docs/shell_completion.md | 31 +++++++++++++++++++++++++++++++ 5 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 docs/shell_completion.md diff --git a/CHANGELOG.md b/CHANGELOG.md index d020355..8f236c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ Changelog +**0.40.0 (TBA)** + +* Migrate to `click` for commandline arguments. BC should be mostly preserved, + please report any issues. +* Add shell completion, see: https://toot.bezdomni.net/shell_completion.html +* Remove deprecated `--disable-https` option for `login` and `login_cli`, pass + the base URL instead + **0.39.0 (2023-11-23)** * Add `--json` option to many commands, this makes them print the JSON data diff --git a/changelog.yaml b/changelog.yaml index 93aed2c..7f7854e 100644 --- a/changelog.yaml +++ b/changelog.yaml @@ -1,8 +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" + - "Migrate to `click` for commandline arguments. BC should be mostly preserved, please report any issues." + - "Add shell completion, see: https://toot.bezdomni.net/shell_completion.html" + - "Remove deprecated `--disable-https` option for `login` and `login_cli`, pass the base URL instead" 0.39.0: date: 2023-11-23 diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 5c67529..d2d19a0 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -6,6 +6,7 @@ - [Usage](usage.md) - [Advanced](advanced.md) - [Settings](settings.md) + - [Shell completion](shell_completion.md) - [TUI](tui.md) - [Contributing](contributing.md) - [Documentation](documentation.md) diff --git a/docs/changelog.md b/docs/changelog.md index d020355..8f236c3 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -3,6 +3,14 @@ Changelog +**0.40.0 (TBA)** + +* Migrate to `click` for commandline arguments. BC should be mostly preserved, + please report any issues. +* Add shell completion, see: https://toot.bezdomni.net/shell_completion.html +* Remove deprecated `--disable-https` option for `login` and `login_cli`, pass + the base URL instead + **0.39.0 (2023-11-23)** * Add `--json` option to many commands, this makes them print the JSON data diff --git a/docs/shell_completion.md b/docs/shell_completion.md new file mode 100644 index 0000000..d4086b8 --- /dev/null +++ b/docs/shell_completion.md @@ -0,0 +1,31 @@ +# Shell completion + +> Introduced in toot 0.40.0 + +Toot uses [Click shell completion](https://click.palletsprojects.com/en/8.1.x/shell-completion/) which works on Bash, Fish and Zsh. + +To enable completion, toot must be [installed](./installation.html) as a command and available by ivoking `toot`. Then follow the instructions for your shell. + +**Bash** + +Add to `~/.bashrc`: + +``` +eval "$(_TOOT_COMPLETE=bash_source toot)" +``` + +**Fish** + +Add to `~/.config/fish/completions/toot.fish`: + +``` +_TOOT_COMPLETE=fish_source toot | source +``` + +**Zsh** + +Add to `~/.zshrc`: + +``` +eval "$(_TOOT_COMPLETE=zsh_source toot)" +``` From 0848a6f7dfa6415f331d9ef90cac14ae0c0e0589 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Thu, 7 Dec 2023 19:41:30 +0100 Subject: [PATCH 42/59] Add shell completion for account names --- toot/cli/auth.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/toot/cli/auth.py b/toot/cli/auth.py index 12d1a74..e5a7c8e 100644 --- a/toot/cli/auth.py +++ b/toot/cli/auth.py @@ -2,6 +2,9 @@ import click import platform import sys import webbrowser +from click.shell_completion import CompletionItem + +from click.types import StringParamType from toot import api, config, __version__ from toot.auth import get_or_create_app, login_auth_code, login_username_password @@ -19,6 +22,18 @@ instance_option = click.option( ) +class AccountParamType(StringParamType): + """Custom type to add shell completion for account names""" + + def shell_complete(self, ctx, param, incomplete: str): + accounts = config.load_config()["users"].keys() + return [ + CompletionItem(a) + for a in accounts + if a.lower().startswith(incomplete.lower()) + ] + + @cli.command() def auth(): """Show logged in accounts and instances""" @@ -101,7 +116,7 @@ def login(base_url: str): @cli.command() -@click.argument("account", required=False) +@click.argument("account", type=AccountParamType(), required=False) def logout(account: str): """Log out of ACCOUNT, delete stored access keys""" accounts = _get_accounts_list() @@ -119,7 +134,7 @@ def logout(account: str): @cli.command() -@click.argument("account", required=False) +@click.argument("account", type=AccountParamType(), required=False) def activate(account: str): """Switch to logged in ACCOUNT.""" accounts = _get_accounts_list() From 875bf2d86afc1113f5fbdb90f42550ec136336dc Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Thu, 7 Dec 2023 20:05:58 +0100 Subject: [PATCH 43/59] Add docs for environment variables --- docs/SUMMARY.md | 1 + docs/environment_variables.md | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 docs/environment_variables.md diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index d2d19a0..5fe913b 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -7,6 +7,7 @@ - [Advanced](advanced.md) - [Settings](settings.md) - [Shell completion](shell_completion.md) + - [Environment variables](environment_variables.md) - [TUI](tui.md) - [Contributing](contributing.md) - [Documentation](documentation.md) diff --git a/docs/environment_variables.md b/docs/environment_variables.md new file mode 100644 index 0000000..4ba05f7 --- /dev/null +++ b/docs/environment_variables.md @@ -0,0 +1,19 @@ +# Environment variables + +> Introduced in toot v0.40.0 + +Toot allows setting defaults for parameters via environment variables. + +Environment variables should be named `TOOT__`. + +### Examples + +Command with option | Environment variable +------------------- | -------------------- +`toot --color` | `TOOT_COLOR=true` +`toot --no-color` | `TOOT_COLOR=false` +`toot post --editor vim` | `TOOT_POST_EDITOR=vim` +`toot post --visibility unlisted` | `TOOT_POST_VISIBILITY=unlisted` +`toot tui --media-viewer feh` | `TOOT_TUI_MEDIA_VIEWER=feh` + +Note that these can also be set via the [settings file](./settings.html). From 9098279d40ece126286f948b5b27f06ed11c15c5 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Fri, 8 Dec 2023 08:23:17 +0100 Subject: [PATCH 44/59] Replace tags_* commands with a group --- toot/cli/tags.py | 58 +++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 47 insertions(+), 11 deletions(-) diff --git a/toot/cli/tags.py b/toot/cli/tags.py index c13d613..5193541 100644 --- a/toot/cli/tags.py +++ b/toot/cli/tags.py @@ -2,32 +2,68 @@ import click from toot import api from toot.cli.base import cli, pass_context, Context -from toot.output import print_tag_list +from toot.output import print_tag_list, print_warning -@cli.command(name="tags_followed") -@pass_context -def tags_followed(ctx: Context): - """List hashtags you follow""" - response = api.followed_tags(ctx.app, ctx.user) - print_tag_list(response) +@cli.group(invoke_without_command=True) +@click.pass_context +def tags(ctx: click.Context): + """List, follow, and unfollow tags + + When invoked without a command, lists followed tags.""" + if ctx.invoked_subcommand is None: + response = api.followed_tags(ctx.obj.app, ctx.obj.user) + print_tag_list(response) -@cli.command(name="tags_follow") +@tags.command() @click.argument("tag") @pass_context -def tags_follow(ctx: Context, tag: str): +def follow(ctx: Context, tag: str): """Follow a hashtag""" tag = tag.lstrip("#") api.follow_tag(ctx.app, ctx.user, tag) click.secho(f"✓ You are now following #{tag}", fg="green") -@cli.command(name="tags_unfollow") +@tags.command() @click.argument("tag") @pass_context -def tags_unfollow(ctx: Context, tag: str): +def unfollow(ctx: Context, tag: str): """Unfollow a hashtag""" tag = tag.lstrip("#") api.unfollow_tag(ctx.app, ctx.user, tag) click.secho(f"✓ You are no longer following #{tag}", fg="green") + + +# -- Deprecated commands ------------------------------------------------------- + +@cli.command(name="tags_followed", hidden=True) +@pass_context +def tags_followed(ctx: Context): + """List hashtags you follow""" + print_warning("`toot tags_followed` is deprecated in favour of `toot tags`") + response = api.followed_tags(ctx.app, ctx.user) + print_tag_list(response) + + +@cli.command(name="tags_follow", hidden=True) +@click.argument("tag") +@pass_context +def tags_follow(ctx: Context, tag: str): + """Follow a hashtag""" + print_warning("`toot tags_follow` is deprecated in favour of `toot tags follow`") + tag = tag.lstrip("#") + api.follow_tag(ctx.app, ctx.user, tag) + click.secho(f"✓ You are now following #{tag}", fg="green") + + +@cli.command(name="tags_unfollow", hidden=True) +@click.argument("tag") +@pass_context +def tags_unfollow(ctx: Context, tag: str): + """Unfollow a hashtag""" + print_warning("`toot tags_unfollow` is deprecated in favour of `toot tags unfollow`") + tag = tag.lstrip("#") + api.unfollow_tag(ctx.app, ctx.user, tag) + click.secho(f"✓ You are no longer following #{tag}", fg="green") From 0f4f0b3863c44ac092874ba82c82943b93504f61 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Fri, 8 Dec 2023 08:44:24 +0100 Subject: [PATCH 45/59] Don't page lists, they don't support paging --- toot/api.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/toot/api.py b/toot/api.py index f9ca29e..151b53f 100644 --- a/toot/api.py +++ b/toot/api.py @@ -589,8 +589,7 @@ def get_instance(base_url: str) -> Response: def get_lists(app, user): - path = "/api/v1/lists" - return _get_response_list(app, user, path) + return http.get(app, user, "/api/v1/lists").json() def find_list_id(app, user, title): From 63691a36377e245ef9fdf147685b25c60f6843ed Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Mon, 11 Dec 2023 13:59:05 +0100 Subject: [PATCH 46/59] Allow editor when not in tty I was told there are legitimate use cases I was not aware of. --- toot/cli/post.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/toot/cli/post.py b/toot/cli/post.py index 138faf4..fa5b578 100644 --- a/toot/cli/post.py +++ b/toot/cli/post.py @@ -130,9 +130,6 @@ def post( json: bool ): """Post a new status""" - if editor and not sys.stdin.isatty(): - raise click.ClickException("Cannot run editor if not in tty.") - if len(media) > 4: raise click.ClickException("Cannot attach more than 4 files.") From c7e01c77f22373b07dee982e1e55cbb52f207554 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Tue, 12 Dec 2023 09:45:57 +0100 Subject: [PATCH 47/59] Add --json option to tag commands --- changelog.yaml | 3 +- tests/integration/test_read.py | 33 ------------- tests/integration/test_tags.py | 84 ++++++++++++++++++++++++++++++++++ toot/api.py | 8 ++-- toot/cli/tags.py | 33 +++++++++---- toot/entities.py | 19 ++++++++ 6 files changed, 132 insertions(+), 48 deletions(-) create mode 100644 tests/integration/test_tags.py diff --git a/changelog.yaml b/changelog.yaml index 7f7854e..0816d17 100644 --- a/changelog.yaml +++ b/changelog.yaml @@ -1,9 +1,10 @@ 0.40.0: date: TBA changes: + - "BREAKING: Remove deprecated `--disable-https` option for `login` and `login_cli`, pass the base URL instead" - "Migrate to `click` for commandline arguments. BC should be mostly preserved, please report any issues." - "Add shell completion, see: https://toot.bezdomni.net/shell_completion.html" - - "Remove deprecated `--disable-https` option for `login` and `login_cli`, pass the base URL instead" + - "Add `--json` option to tag commands" 0.39.0: date: 2023-11-23 diff --git a/tests/integration/test_read.py b/tests/integration/test_read.py index 78cd231..6bd2bf0 100644 --- a/tests/integration/test_read.py +++ b/tests/integration/test_read.py @@ -130,39 +130,6 @@ def test_search_hashtag_json(app, user, run): assert h3["name"] == "hashtag_z" -def test_tags(run, base_url): - result = run(cli.tags_followed) - assert result.exit_code == 0 - assert result.stdout.strip() == "You're not following any hashtags." - - result = run(cli.tags_follow, "foo") - assert result.exit_code == 0 - assert result.stdout.strip() == "✓ You are now following #foo" - - result = run(cli.tags_followed) - assert result.exit_code == 0 - assert result.stdout.strip() == f"* #foo\t{base_url}/tags/foo" - - result = run(cli.tags_follow, "bar") - assert result.exit_code == 0 - assert result.stdout.strip() == "✓ You are now following #bar" - - result = run(cli.tags_followed) - assert result.exit_code == 0 - assert result.stdout.strip() == "\n".join([ - f"* #bar\t{base_url}/tags/bar", - f"* #foo\t{base_url}/tags/foo", - ]) - - result = run(cli.tags_unfollow, "foo") - assert result.exit_code == 0 - assert result.stdout.strip() == "✓ You are no longer following #foo" - - result = run(cli.tags_followed) - assert result.exit_code == 0 - assert result.stdout.strip() == f"* #bar\t{base_url}/tags/bar" - - def test_status(app, user, run): uuid = str(uuid4()) status_id = api.post_status(app, user, uuid).json()["id"] diff --git a/tests/integration/test_tags.py b/tests/integration/test_tags.py new file mode 100644 index 0000000..491d2c9 --- /dev/null +++ b/tests/integration/test_tags.py @@ -0,0 +1,84 @@ +from toot import cli +from toot.entities import Tag, from_dict, from_dict_list + + +def test_tags(run, base_url): + result = run(cli.tags) + assert result.exit_code == 0 + assert result.stdout.strip() == "You're not following any hashtags." + + result = run(cli.tags, "follow", "foo") + assert result.exit_code == 0 + assert result.stdout.strip() == "✓ You are now following #foo" + + result = run(cli.tags) + assert result.exit_code == 0 + assert result.stdout.strip() == f"* #foo\t{base_url}/tags/foo" + + result = run(cli.tags, "follow", "bar") + assert result.exit_code == 0 + assert result.stdout.strip() == "✓ You are now following #bar" + + result = run(cli.tags) + assert result.exit_code == 0 + assert result.stdout.strip() == "\n".join([ + f"* #bar\t{base_url}/tags/bar", + f"* #foo\t{base_url}/tags/foo", + ]) + + result = run(cli.tags, "unfollow", "foo") + assert result.exit_code == 0 + assert result.stdout.strip() == "✓ You are no longer following #foo" + + result = run(cli.tags) + assert result.exit_code == 0 + assert result.stdout.strip() == f"* #bar\t{base_url}/tags/bar" + + result = run(cli.tags, "unfollow", "bar") + assert result.exit_code == 0 + assert result.stdout.strip() == "✓ You are no longer following #bar" + + result = run(cli.tags) + assert result.exit_code == 0 + assert result.stdout.strip() == "You're not following any hashtags." + + +def test_tags_json(run_json): + result = run_json(cli.tags, "--json") + assert result == [] + + result = run_json(cli.tags, "follow", "foo", "--json") + tag = from_dict(Tag, result) + assert tag.name == "foo" + assert tag.following is True + + result = run_json(cli.tags, "--json") + [tag] = from_dict_list(Tag, result) + assert tag.name == "foo" + assert tag.following is True + + result = run_json(cli.tags, "follow", "bar", "--json") + tag = from_dict(Tag, result) + assert tag.name == "bar" + assert tag.following is True + + result = run_json(cli.tags, "--json") + tags = from_dict_list(Tag, result) + [bar, foo] = sorted(tags, key=lambda t: t.name) + assert foo.name == "foo" + assert foo.following is True + assert bar.name == "bar" + assert bar.following is True + + result = run_json(cli.tags, "unfollow", "foo", "--json") + tag = from_dict(Tag, result) + assert tag.name == "foo" + assert tag.following is False + + result = run_json(cli.tags, "unfollow", "bar", "--json") + tag = from_dict(Tag, result) + assert tag.name == "bar" + assert tag.following is False + + result = run_json(cli.tags, "--json") + assert result == [] diff --git a/toot/api.py b/toot/api.py index 151b53f..2a58f30 100644 --- a/toot/api.py +++ b/toot/api.py @@ -48,9 +48,9 @@ def _status_action(app, user, status_id, action, data=None) -> Response: return http.post(app, user, url, data=data) -def _tag_action(app, user, tag_name, action): +def _tag_action(app, user, tag_name, action) -> Response: url = f"/api/v1/tags/{tag_name}/{action}" - return http.post(app, user, url).json() + return http.post(app, user, url) def create_app(base_url): @@ -499,11 +499,11 @@ def unfollow(app, user, account): return _account_action(app, user, account, 'unfollow') -def follow_tag(app, user, tag_name): +def follow_tag(app, user, tag_name) -> Response: return _tag_action(app, user, tag_name, 'follow') -def unfollow_tag(app, user, tag_name): +def unfollow_tag(app, user, tag_name) -> Response: return _tag_action(app, user, tag_name, 'unfollow') diff --git a/toot/cli/tags.py b/toot/cli/tags.py index 5193541..40c0800 100644 --- a/toot/cli/tags.py +++ b/toot/cli/tags.py @@ -1,39 +1,52 @@ import click +import json as pyjson from toot import api -from toot.cli.base import cli, pass_context, Context +from toot.cli.base import cli, pass_context, json_option, Context from toot.output import print_tag_list, print_warning @cli.group(invoke_without_command=True) +@json_option @click.pass_context -def tags(ctx: click.Context): +def tags(ctx: click.Context, json): """List, follow, and unfollow tags When invoked without a command, lists followed tags.""" if ctx.invoked_subcommand is None: - response = api.followed_tags(ctx.obj.app, ctx.obj.user) - print_tag_list(response) + tags = api.followed_tags(ctx.obj.app, ctx.obj.user) + if json: + click.echo(pyjson.dumps(tags)) + else: + print_tag_list(tags) @tags.command() @click.argument("tag") +@json_option @pass_context -def follow(ctx: Context, tag: str): +def follow(ctx: Context, tag: str, json: bool): """Follow a hashtag""" tag = tag.lstrip("#") - api.follow_tag(ctx.app, ctx.user, tag) - click.secho(f"✓ You are now following #{tag}", fg="green") + response = api.follow_tag(ctx.app, ctx.user, tag) + if json: + click.echo(response.text) + else: + click.secho(f"✓ You are now following #{tag}", fg="green") @tags.command() @click.argument("tag") +@json_option @pass_context -def unfollow(ctx: Context, tag: str): +def unfollow(ctx: Context, tag: str, json: bool): """Unfollow a hashtag""" tag = tag.lstrip("#") - api.unfollow_tag(ctx.app, ctx.user, tag) - click.secho(f"✓ You are no longer following #{tag}", fg="green") + response = api.unfollow_tag(ctx.app, ctx.user, tag) + if json: + click.echo(response.text) + else: + click.secho(f"✓ You are no longer following #{tag}", fg="green") # -- Deprecated commands ------------------------------------------------------- diff --git a/toot/entities.py b/toot/entities.py index 8ef51e3..47d0cd7 100644 --- a/toot/entities.py +++ b/toot/entities.py @@ -409,6 +409,25 @@ class Relationship: note: str +@dataclass +class TagHistory: + day: str + uses: str + accounts: str + + +@dataclass +class Tag: + """ + Represents a hashtag used within the content of a status. + https://docs.joinmastodon.org/entities/Tag/ + """ + name: str + url: str + history: List[TagHistory] + following: Optional[bool] + + # Generic data class instance T = TypeVar("T") From a0caa88ffe827b15461a2b30829466124090a35c Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Tue, 12 Dec 2023 10:11:36 +0100 Subject: [PATCH 48/59] Add insurance policy --- toot/config.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/toot/config.py b/toot/config.py index 4548ea5..21861e6 100644 --- a/toot/config.py +++ b/toot/config.py @@ -39,6 +39,10 @@ def make_config(path): def load_config(): + # Just to prevent accidentally running tests on production + if os.environ.get("TOOT_TESTING"): + raise Exception("Tests should not access the config file!") + path = get_config_file_path() if not os.path.exists(path): From 743dfd715e262bd0c30111a67cb0d48469acac22 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Tue, 12 Dec 2023 10:29:34 +0100 Subject: [PATCH 49/59] Change `toot tags` to `toot tags followed` --- tests/integration/test_tags.py | 18 +++++++++--------- toot/cli/tags.py | 28 +++++++++++++++------------- 2 files changed, 24 insertions(+), 22 deletions(-) diff --git a/tests/integration/test_tags.py b/tests/integration/test_tags.py index 491d2c9..bc97f78 100644 --- a/tests/integration/test_tags.py +++ b/tests/integration/test_tags.py @@ -3,7 +3,7 @@ from toot.entities import Tag, from_dict, from_dict_list def test_tags(run, base_url): - result = run(cli.tags) + result = run(cli.tags, "followed") assert result.exit_code == 0 assert result.stdout.strip() == "You're not following any hashtags." @@ -11,7 +11,7 @@ def test_tags(run, base_url): assert result.exit_code == 0 assert result.stdout.strip() == "✓ You are now following #foo" - result = run(cli.tags) + result = run(cli.tags, "followed") assert result.exit_code == 0 assert result.stdout.strip() == f"* #foo\t{base_url}/tags/foo" @@ -19,7 +19,7 @@ def test_tags(run, base_url): assert result.exit_code == 0 assert result.stdout.strip() == "✓ You are now following #bar" - result = run(cli.tags) + result = run(cli.tags, "followed") assert result.exit_code == 0 assert result.stdout.strip() == "\n".join([ f"* #bar\t{base_url}/tags/bar", @@ -30,7 +30,7 @@ def test_tags(run, base_url): assert result.exit_code == 0 assert result.stdout.strip() == "✓ You are no longer following #foo" - result = run(cli.tags) + result = run(cli.tags, "followed") assert result.exit_code == 0 assert result.stdout.strip() == f"* #bar\t{base_url}/tags/bar" @@ -38,13 +38,13 @@ def test_tags(run, base_url): assert result.exit_code == 0 assert result.stdout.strip() == "✓ You are no longer following #bar" - result = run(cli.tags) + result = run(cli.tags, "followed") assert result.exit_code == 0 assert result.stdout.strip() == "You're not following any hashtags." def test_tags_json(run_json): - result = run_json(cli.tags, "--json") + result = run_json(cli.tags, "followed", "--json") assert result == [] result = run_json(cli.tags, "follow", "foo", "--json") @@ -52,7 +52,7 @@ def test_tags_json(run_json): assert tag.name == "foo" assert tag.following is True - result = run_json(cli.tags, "--json") + result = run_json(cli.tags, "followed", "--json") [tag] = from_dict_list(Tag, result) assert tag.name == "foo" assert tag.following is True @@ -62,7 +62,7 @@ def test_tags_json(run_json): assert tag.name == "bar" assert tag.following is True - result = run_json(cli.tags, "--json") + result = run_json(cli.tags, "followed", "--json") tags = from_dict_list(Tag, result) [bar, foo] = sorted(tags, key=lambda t: t.name) assert foo.name == "foo" @@ -80,5 +80,5 @@ def test_tags_json(run_json): assert tag.name == "bar" assert tag.following is False - result = run_json(cli.tags, "--json") + result = run_json(cli.tags, "followed", "--json") assert result == [] diff --git a/toot/cli/tags.py b/toot/cli/tags.py index 40c0800..852c0f7 100644 --- a/toot/cli/tags.py +++ b/toot/cli/tags.py @@ -6,19 +6,21 @@ from toot.cli.base import cli, pass_context, json_option, Context from toot.output import print_tag_list, print_warning -@cli.group(invoke_without_command=True) -@json_option -@click.pass_context -def tags(ctx: click.Context, json): - """List, follow, and unfollow tags +@cli.group() +def tags(): + """List, follow, and unfollow tags""" - When invoked without a command, lists followed tags.""" - if ctx.invoked_subcommand is None: - tags = api.followed_tags(ctx.obj.app, ctx.obj.user) - if json: - click.echo(pyjson.dumps(tags)) - else: - print_tag_list(tags) + +@tags.command() +@json_option +@pass_context +def followed(ctx: Context, json: bool): + """List followed tags""" + tags = api.followed_tags(ctx.app, ctx.user) + if json: + click.echo(pyjson.dumps(tags)) + else: + print_tag_list(tags) @tags.command() @@ -55,7 +57,7 @@ def unfollow(ctx: Context, tag: str, json: bool): @pass_context def tags_followed(ctx: Context): """List hashtags you follow""" - print_warning("`toot tags_followed` is deprecated in favour of `toot tags`") + print_warning("`toot tags_followed` is deprecated in favour of `toot tags followed`") response = api.followed_tags(ctx.app, ctx.user) print_tag_list(response) From 381e3583ef84b5a05f4f5d28b12b72cb0e6e864e Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Wed, 13 Dec 2023 07:50:45 +0100 Subject: [PATCH 50/59] Add featured tag commands --- tests/integration/test_tags.py | 101 +++++++++++++++++++++++++++++---- toot/api.py | 23 ++++++++ toot/cli/tags.py | 57 ++++++++++++++++++- toot/entities.py | 17 ++++++ toot/output.py | 7 +-- 5 files changed, 188 insertions(+), 17 deletions(-) diff --git a/tests/integration/test_tags.py b/tests/integration/test_tags.py index bc97f78..eaddaba 100644 --- a/tests/integration/test_tags.py +++ b/tests/integration/test_tags.py @@ -1,11 +1,14 @@ -from toot import cli -from toot.entities import Tag, from_dict, from_dict_list +import re +from typing import List + +from toot import api, cli +from toot.entities import FeaturedTag, Tag, from_dict, from_dict_list -def test_tags(run, base_url): +def test_tags(run): result = run(cli.tags, "followed") assert result.exit_code == 0 - assert result.stdout.strip() == "You're not following any hashtags." + assert result.stdout.strip() == "You're not following any hashtags" result = run(cli.tags, "follow", "foo") assert result.exit_code == 0 @@ -13,7 +16,7 @@ def test_tags(run, base_url): result = run(cli.tags, "followed") assert result.exit_code == 0 - assert result.stdout.strip() == f"* #foo\t{base_url}/tags/foo" + assert _find_tags(result.stdout) == ["#foo"] result = run(cli.tags, "follow", "bar") assert result.exit_code == 0 @@ -21,10 +24,7 @@ def test_tags(run, base_url): result = run(cli.tags, "followed") assert result.exit_code == 0 - assert result.stdout.strip() == "\n".join([ - f"* #bar\t{base_url}/tags/bar", - f"* #foo\t{base_url}/tags/foo", - ]) + assert _find_tags(result.stdout) == ["#bar", "#foo"] result = run(cli.tags, "unfollow", "foo") assert result.exit_code == 0 @@ -32,7 +32,7 @@ def test_tags(run, base_url): result = run(cli.tags, "followed") assert result.exit_code == 0 - assert result.stdout.strip() == f"* #bar\t{base_url}/tags/bar" + assert _find_tags(result.stdout) == ["#bar"] result = run(cli.tags, "unfollow", "bar") assert result.exit_code == 0 @@ -40,7 +40,7 @@ def test_tags(run, base_url): result = run(cli.tags, "followed") assert result.exit_code == 0 - assert result.stdout.strip() == "You're not following any hashtags." + assert result.stdout.strip() == "You're not following any hashtags" def test_tags_json(run_json): @@ -82,3 +82,82 @@ def test_tags_json(run_json): result = run_json(cli.tags, "followed", "--json") assert result == [] + + +def test_tags_featured(run, app, user): + result = run(cli.tags, "featured") + assert result.exit_code == 0 + assert result.stdout.strip() == "You don't have any featured hashtags" + + result = run(cli.tags, "feature", "foo") + assert result.exit_code == 0 + assert result.stdout.strip() == "✓ Tag #foo is now featured" + + result = run(cli.tags, "featured") + assert result.exit_code == 0 + assert _find_tags(result.stdout) == ["#foo"] + + result = run(cli.tags, "feature", "bar") + assert result.exit_code == 0 + assert result.stdout.strip() == "✓ Tag #bar is now featured" + + result = run(cli.tags, "featured") + assert result.exit_code == 0 + assert _find_tags(result.stdout) == ["#bar", "#foo"] + + # Unfeature by Name + result = run(cli.tags, "unfeature", "foo") + assert result.exit_code == 0 + assert result.stdout.strip() == "✓ Tag #foo is no longer featured" + + result = run(cli.tags, "featured") + assert result.exit_code == 0 + assert _find_tags(result.stdout) == ["#bar"] + + # Unfeature by ID + tag = api.find_featured_tag(app, user, "bar") + assert tag is not None + + result = run(cli.tags, "unfeature", tag["id"]) + assert result.exit_code == 0 + assert result.stdout.strip() == "✓ Tag #bar is no longer featured" + + result = run(cli.tags, "featured") + assert result.exit_code == 0 + assert result.stdout.strip() == "You don't have any featured hashtags" + + +def test_tags_featured_json(run_json): + result = run_json(cli.tags, "featured", "--json") + assert result == [] + + result = run_json(cli.tags, "feature", "foo", "--json") + tag = from_dict(FeaturedTag, result) + assert tag.name == "foo" + + result = run_json(cli.tags, "featured", "--json") + [tag] = from_dict_list(FeaturedTag, result) + assert tag.name == "foo" + + result = run_json(cli.tags, "feature", "bar", "--json") + tag = from_dict(FeaturedTag, result) + assert tag.name == "bar" + + result = run_json(cli.tags, "featured", "--json") + tags = from_dict_list(FeaturedTag, result) + [bar, foo] = sorted(tags, key=lambda t: t.name) + assert foo.name == "foo" + assert bar.name == "bar" + + result = run_json(cli.tags, "unfeature", "foo", "--json") + assert result == {} + + result = run_json(cli.tags, "unfeature", "bar", "--json") + assert result == {} + + result = run_json(cli.tags, "featured", "--json") + assert result == [] + + +def _find_tags(txt: str) -> List[str]: + return sorted(re.findall(r"#\w+", txt)) diff --git a/toot/api.py b/toot/api.py index 2a58f30..4775aa3 100644 --- a/toot/api.py +++ b/toot/api.py @@ -531,6 +531,29 @@ def followed_tags(app, user): return _get_response_list(app, user, path) +def featured_tags(app, user): + return http.get(app, user, "/api/v1/featured_tags") + + +def feature_tag(app, user, tag: str) -> Response: + return http.post(app, user, "/api/v1/featured_tags", data={"name": tag}) + + +def unfeature_tag(app, user, tag_id: str) -> Response: + return http.delete(app, user, f"/api/v1/featured_tags/{tag_id}") + + +def find_featured_tag(app, user, tag) -> Optional[dict]: + """Find a featured tag by tag name or ID""" + return next( + ( + t for t in featured_tags(app, user).json() + if t["name"].lower() == tag.lstrip("#").lower() or t["id"] == tag + ), + None + ) + + def whois(app, user, account): return http.get(app, user, f'/api/v1/accounts/{account}').json() diff --git a/toot/cli/tags.py b/toot/cli/tags.py index 852c0f7..1621d66 100644 --- a/toot/cli/tags.py +++ b/toot/cli/tags.py @@ -20,7 +20,10 @@ def followed(ctx: Context, json: bool): if json: click.echo(pyjson.dumps(tags)) else: - print_tag_list(tags) + if tags: + print_tag_list(tags) + else: + click.echo("You're not following any hashtags") @tags.command() @@ -51,6 +54,58 @@ def unfollow(ctx: Context, tag: str, json: bool): click.secho(f"✓ You are no longer following #{tag}", fg="green") +@tags.command() +@json_option +@pass_context +def featured(ctx: Context, json: bool): + """List hashtags featured on your profile.""" + response = api.featured_tags(ctx.app, ctx.user) + if json: + click.echo(response.text) + else: + tags = response.json() + if tags: + print_tag_list(tags) + else: + click.echo("You don't have any featured hashtags") + + +@tags.command() +@click.argument("tag") +@json_option +@pass_context +def feature(ctx: Context, tag: str, json: bool): + """Feature a hashtag on your profile""" + tag = tag.lstrip("#") + response = api.feature_tag(ctx.app, ctx.user, tag) + if json: + click.echo(response.text) + else: + click.secho(f"✓ Tag #{tag} is now featured", fg="green") + + +@tags.command() +@click.argument("tag") +@json_option +@pass_context +def unfeature(ctx: Context, tag: str, json: bool): + """Unfollow a hashtag + + TAG can either be a tag name like "#foo" or "foo" or a tag ID. + """ + featured_tag = api.find_featured_tag(ctx.app, ctx.user, tag) + + # TODO: should this be idempotent? + if not featured_tag: + raise click.ClickException(f"Tag {tag} is not featured") + + response = api.unfeature_tag(ctx.app, ctx.user, featured_tag["id"]) + if json: + click.echo(response.text) + else: + click.secho(f"✓ Tag #{featured_tag['name']} is no longer featured", fg="green") + + # -- Deprecated commands ------------------------------------------------------- @cli.command(name="tags_followed", hidden=True) diff --git a/toot/entities.py b/toot/entities.py index 47d0cd7..309d84e 100644 --- a/toot/entities.py +++ b/toot/entities.py @@ -411,6 +411,10 @@ class Relationship: @dataclass class TagHistory: + """ + Usage statistics for given days (typically the past week). + https://docs.joinmastodon.org/entities/Tag/#history + """ day: str uses: str accounts: str @@ -428,6 +432,19 @@ class Tag: following: Optional[bool] +@dataclass +class FeaturedTag: + """ + Represents a hashtag that is featured on a profile. + https://docs.joinmastodon.org/entities/FeaturedTag/ + """ + id: str + name: str + url: str + statuses_count: int + last_status_at: datetime + + # Generic data class instance T = TypeVar("T") diff --git a/toot/output.py b/toot/output.py index 02f1083..266f467 100644 --- a/toot/output.py +++ b/toot/output.py @@ -115,11 +115,8 @@ def print_acct_list(accounts): def print_tag_list(tags): - if tags: - for tag in tags: - click.echo(f"* {format_tag_name(tag)}\t{tag['url']}") - else: - click.echo("You're not following any hashtags.") + for tag in tags: + click.echo(f"* {format_tag_name(tag)}\t{tag['url']}") def print_lists(lists): From 01f3370b8915f5b35f87481f44cd1ac22586e6c1 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Wed, 13 Dec 2023 08:37:43 +0100 Subject: [PATCH 51/59] Add `tags info` command --- changelog.yaml | 1 + toot/api.py | 14 ++++++++++++++ toot/cli/tags.py | 24 ++++++++++++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/changelog.yaml b/changelog.yaml index 0816d17..5713612 100644 --- a/changelog.yaml +++ b/changelog.yaml @@ -5,6 +5,7 @@ - "Migrate to `click` for commandline arguments. BC should be mostly preserved, please report any issues." - "Add shell completion, see: https://toot.bezdomni.net/shell_completion.html" - "Add `--json` option to tag commands" + - "Add `tags info`, `tags featured`, `tags feature`, and `tags unfeature` commands" 0.39.0: date: 2023-11-23 diff --git a/toot/api.py b/toot/api.py index 4775aa3..1a7d3e5 100644 --- a/toot/api.py +++ b/toot/api.py @@ -543,6 +543,20 @@ def unfeature_tag(app, user, tag_id: str) -> Response: return http.delete(app, user, f"/api/v1/featured_tags/{tag_id}") +def find_tag(app, user, tag) -> Optional[dict]: + """Find a hashtag by tag name or ID""" + tag = tag.lstrip("#") + results = search(app, user, tag, type="hashtags").json() + + return next( + ( + t for t in results["hashtags"] + if t["name"].lower() == tag.lstrip("#").lower() or t["id"] == tag + ), + None + ) + + def find_featured_tag(app, user, tag) -> Optional[dict]: """Find a featured tag by tag name or ID""" return next( diff --git a/toot/cli/tags.py b/toot/cli/tags.py index 1621d66..2e8d40a 100644 --- a/toot/cli/tags.py +++ b/toot/cli/tags.py @@ -3,6 +3,7 @@ import json as pyjson from toot import api from toot.cli.base import cli, pass_context, json_option, Context +from toot.entities import Tag, from_dict from toot.output import print_tag_list, print_warning @@ -11,6 +12,29 @@ def tags(): """List, follow, and unfollow tags""" +@tags.command() +@click.argument("tag") +@json_option +@pass_context +def info(ctx: Context, tag, json: bool): + """Show a hashtag and its associated information""" + tag = api.find_tag(ctx.app, ctx.user, tag) + + if not tag: + raise click.ClickException("Tag not found") + + if json: + click.echo(pyjson.dumps(tag)) + else: + tag = from_dict(Tag, tag) + click.secho(f"#{tag.name}", fg="yellow") + click.secho(tag.url, italic=True) + if tag.following: + click.echo("Followed") + else: + click.echo("Not followed") + + @tags.command() @json_option @pass_context From 120545865b96664bcbeecf6406c7719e90c633aa Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Wed, 13 Dec 2023 08:40:30 +0100 Subject: [PATCH 52/59] Bump version to 0.40.0 --- setup.py | 2 +- toot/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 79e4833..aca9b65 100644 --- a/setup.py +++ b/setup.py @@ -12,7 +12,7 @@ and blocking accounts and other actions. setup( name='toot', - version='0.39.0', + version='0.40.0', description='Mastodon CLI client', long_description=long_description.strip(), author='Ivan Habunek', diff --git a/toot/__init__.py b/toot/__init__.py index 43f19a4..010b17a 100644 --- a/toot/__init__.py +++ b/toot/__init__.py @@ -4,7 +4,7 @@ import sys from os.path import join, expanduser from typing import NamedTuple -__version__ = '0.39.0' +__version__ = '0.40.0' class App(NamedTuple): From fab23b9069470f745592dd36eef6295b594c2ac9 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Wed, 13 Dec 2023 14:16:30 +0100 Subject: [PATCH 53/59] Reorganize cli imports The old way did not allow for having multiple commands of the same name --- tests/integration/test_accounts.py | 92 ++++++++++++------------ tests/integration/test_auth.py | 26 +++---- tests/integration/test_lists.py | 38 +++++----- tests/integration/test_post.py | 44 ++++++------ tests/integration/test_read.py | 34 ++++----- tests/integration/test_status.py | 42 +++++------ tests/integration/test_tags.py | 68 +++++++++--------- tests/integration/test_timelines.py | 42 +++++------ tests/integration/test_update_account.py | 32 ++++----- toot/cli/__init__.py | 18 ++--- 10 files changed, 218 insertions(+), 218 deletions(-) diff --git a/tests/integration/test_accounts.py b/tests/integration/test_accounts.py index f13f855..1caa807 100644 --- a/tests/integration/test_accounts.py +++ b/tests/integration/test_accounts.py @@ -5,7 +5,7 @@ from toot.entities import Account, Relationship, from_dict def test_whoami(user: User, run): - result = run(cli.whoami) + result = run(cli.read.whoami) assert result.exit_code == 0 # TODO: test other fields once updating account is supported @@ -14,7 +14,7 @@ def test_whoami(user: User, run): def test_whoami_json(user: User, run): - result = run(cli.whoami, "--json") + result = run(cli.read.whoami, "--json") assert result.exit_code == 0 account = from_dict(Account, json.loads(result.stdout)) @@ -30,7 +30,7 @@ def test_whois(app: App, friend: User, run): ] for username in variants: - result = run(cli.whois, username) + result = run(cli.read.whois, username) assert result.exit_code == 0 assert f"@{friend.username}" in result.stdout @@ -39,35 +39,35 @@ def test_following(app: App, user: User, friend: User, friend_id, run): # Make sure we're not initally following friend api.unfollow(app, user, friend_id) - result = run(cli.following, user.username) + result = run(cli.accounts.following, user.username) assert result.exit_code == 0 assert result.stdout.strip() == "" - result = run(cli.follow, friend.username) + result = run(cli.accounts.follow, friend.username) assert result.exit_code == 0 assert result.stdout.strip() == f"✓ You are now following {friend.username}" - result = run(cli.following, user.username) + result = run(cli.accounts.following, user.username) assert result.exit_code == 0 assert friend.username in result.stdout.strip() # If no account is given defaults to logged in user - result = run(cli.following) + result = run(cli.accounts.following) assert result.exit_code == 0 assert friend.username in result.stdout.strip() - result = run(cli.unfollow, friend.username) + result = run(cli.accounts.unfollow, friend.username) assert result.exit_code == 0 assert result.stdout.strip() == f"✓ You are no longer following {friend.username}" - result = run(cli.following, user.username) + result = run(cli.accounts.following, user.username) assert result.exit_code == 0 assert result.stdout.strip() == "" def test_following_case_insensitive(user: User, friend: User, run): assert friend.username != friend.username.upper() - result = run(cli.follow, friend.username.upper()) + result = run(cli.accounts.follow, friend.username.upper()) assert result.exit_code == 0 out = result.stdout.strip() @@ -75,11 +75,11 @@ def test_following_case_insensitive(user: User, friend: User, run): def test_following_not_found(run): - result = run(cli.follow, "bananaman") + result = run(cli.accounts.follow, "bananaman") assert result.exit_code == 1 assert result.stderr.strip() == "Error: Account not found" - result = run(cli.unfollow, "bananaman") + result = run(cli.accounts.unfollow, "bananaman") assert result.exit_code == 1 assert result.stderr.strip() == "Error: Account not found" @@ -88,37 +88,37 @@ def test_following_json(app: App, user: User, friend: User, user_id, friend_id, # Make sure we're not initally following friend api.unfollow(app, user, friend_id) - result = run_json(cli.following, user.username, "--json") + result = run_json(cli.accounts.following, user.username, "--json") assert result == [] - result = run_json(cli.followers, friend.username, "--json") + result = run_json(cli.accounts.followers, friend.username, "--json") assert result == [] - result = run_json(cli.follow, friend.username, "--json") + result = run_json(cli.accounts.follow, friend.username, "--json") relationship = from_dict(Relationship, result) assert relationship.id == friend_id assert relationship.following is True - [result] = run_json(cli.following, user.username, "--json") + [result] = run_json(cli.accounts.following, user.username, "--json") relationship = from_dict(Relationship, result) assert relationship.id == friend_id # If no account is given defaults to logged in user - [result] = run_json(cli.following, user.username, "--json") + [result] = run_json(cli.accounts.following, user.username, "--json") relationship = from_dict(Relationship, result) assert relationship.id == friend_id - [result] = run_json(cli.followers, friend.username, "--json") + [result] = run_json(cli.accounts.followers, friend.username, "--json") assert result["id"] == user_id - result = run_json(cli.unfollow, friend.username, "--json") + result = run_json(cli.accounts.unfollow, friend.username, "--json") assert result["id"] == friend_id assert result["following"] is False - result = run_json(cli.following, user.username, "--json") + result = run_json(cli.accounts.following, user.username, "--json") assert result == [] - result = run_json(cli.followers, friend.username, "--json") + result = run_json(cli.accounts.followers, friend.username, "--json") assert result == [] @@ -126,31 +126,31 @@ def test_mute(app, user, friend, friend_id, run): # Make sure we're not initially muting friend api.unmute(app, user, friend_id) - result = run(cli.muted) + result = run(cli.accounts.muted) assert result.exit_code == 0 out = result.stdout.strip() assert out == "No accounts muted" - result = run(cli.mute, friend.username) + result = run(cli.accounts.mute, friend.username) assert result.exit_code == 0 out = result.stdout.strip() assert out == f"✓ You have muted {friend.username}" - result = run(cli.muted) + result = run(cli.accounts.muted) assert result.exit_code == 0 out = result.stdout.strip() assert friend.username in out - result = run(cli.unmute, friend.username) + result = run(cli.accounts.unmute, friend.username) assert result.exit_code == 0 out = result.stdout.strip() assert out == f"✓ {friend.username} is no longer muted" - result = run(cli.muted) + result = run(cli.accounts.muted) assert result.exit_code == 0 out = result.stdout.strip() @@ -158,7 +158,7 @@ def test_mute(app, user, friend, friend_id, run): def test_mute_case_insensitive(friend: User, run): - result = run(cli.mute, friend.username.upper()) + result = run(cli.accounts.mute, friend.username.upper()) assert result.exit_code == 0 out = result.stdout.strip() @@ -166,11 +166,11 @@ def test_mute_case_insensitive(friend: User, run): def test_mute_not_found(run): - result = run(cli.mute, "doesnotexistperson") + result = run(cli.accounts.mute, "doesnotexistperson") assert result.exit_code == 1 assert result.stderr.strip() == "Error: Account not found" - result = run(cli.unmute, "doesnotexistperson") + result = run(cli.accounts.unmute, "doesnotexistperson") assert result.exit_code == 1 assert result.stderr.strip() == "Error: Account not found" @@ -179,24 +179,24 @@ def test_mute_json(app: App, user: User, friend: User, run_json, friend_id): # Make sure we're not initially muting friend api.unmute(app, user, friend_id) - result = run_json(cli.muted, "--json") + result = run_json(cli.accounts.muted, "--json") assert result == [] - result = run_json(cli.mute, friend.username, "--json") + result = run_json(cli.accounts.mute, friend.username, "--json") relationship = from_dict(Relationship, result) assert relationship.id == friend_id assert relationship.muting is True - [result] = run_json(cli.muted, "--json") + [result] = run_json(cli.accounts.muted, "--json") account = from_dict(Account, result) assert account.id == friend_id - result = run_json(cli.unmute, friend.username, "--json") + result = run_json(cli.accounts.unmute, friend.username, "--json") relationship = from_dict(Relationship, result) assert relationship.id == friend_id assert relationship.muting is False - result = run_json(cli.muted, "--json") + result = run_json(cli.accounts.muted, "--json") assert result == [] @@ -204,31 +204,31 @@ def test_block(app, user, friend, friend_id, run): # Make sure we're not initially blocking friend api.unblock(app, user, friend_id) - result = run(cli.blocked) + result = run(cli.accounts.blocked) assert result.exit_code == 0 out = result.stdout.strip() assert out == "No accounts blocked" - result = run(cli.block, friend.username) + result = run(cli.accounts.block, friend.username) assert result.exit_code == 0 out = result.stdout.strip() assert out == f"✓ You are now blocking {friend.username}" - result = run(cli.blocked) + result = run(cli.accounts.blocked) assert result.exit_code == 0 out = result.stdout.strip() assert friend.username in out - result = run(cli.unblock, friend.username) + result = run(cli.accounts.unblock, friend.username) assert result.exit_code == 0 out = result.stdout.strip() assert out == f"✓ {friend.username} is no longer blocked" - result = run(cli.blocked) + result = run(cli.accounts.blocked) assert result.exit_code == 0 out = result.stdout.strip() @@ -236,7 +236,7 @@ def test_block(app, user, friend, friend_id, run): def test_block_case_insensitive(friend: User, run): - result = run(cli.block, friend.username.upper()) + result = run(cli.accounts.block, friend.username.upper()) assert result.exit_code == 0 out = result.stdout.strip() @@ -244,7 +244,7 @@ def test_block_case_insensitive(friend: User, run): def test_block_not_found(run): - result = run(cli.block, "doesnotexistperson") + result = run(cli.accounts.block, "doesnotexistperson") assert result.exit_code == 1 assert result.stderr.strip() == "Error: Account not found" @@ -253,22 +253,22 @@ def test_block_json(app: App, user: User, friend: User, run_json, friend_id): # Make sure we're not initially blocking friend api.unblock(app, user, friend_id) - result = run_json(cli.blocked, "--json") + result = run_json(cli.accounts.blocked, "--json") assert result == [] - result = run_json(cli.block, friend.username, "--json") + result = run_json(cli.accounts.block, friend.username, "--json") relationship = from_dict(Relationship, result) assert relationship.id == friend_id assert relationship.blocking is True - [result] = run_json(cli.blocked, "--json") + [result] = run_json(cli.accounts.blocked, "--json") account = from_dict(Account, result) assert account.id == friend_id - result = run_json(cli.unblock, friend.username, "--json") + result = run_json(cli.accounts.unblock, friend.username, "--json") relationship = from_dict(Relationship, result) assert relationship.id == friend_id assert relationship.blocking is False - result = run_json(cli.blocked, "--json") + result = run_json(cli.accounts.blocked, "--json") assert result == [] diff --git a/tests/integration/test_auth.py b/tests/integration/test_auth.py index 446f8ad..1cc0672 100644 --- a/tests/integration/test_auth.py +++ b/tests/integration/test_auth.py @@ -46,7 +46,7 @@ SAMPLE_CONFIG = { def test_env(run: Run): - result = run(cli.env) + result = run(cli.auth.env) assert result.exit_code == 0 assert "toot" in result.stdout assert "Python" in result.stdout @@ -55,7 +55,7 @@ def test_env(run: Run): @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) + result = run(cli.auth.auth) assert result.exit_code == 0 assert result.stdout.strip() == "You are not logged in to any accounts" @@ -63,7 +63,7 @@ def test_auth_empty(load_config: MagicMock, run: Run): @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) + result = run(cli.auth.auth) assert result.exit_code == 0 assert result.stdout.strip().startswith("Authenticated accounts:") assert "frank@foo.social" in result.stdout @@ -86,7 +86,7 @@ def test_login_cli( load_app.return_value = None result = run( - cli.login_cli, + cli.auth.login_cli, "--instance", "http://localhost:3000", "--email", f"{user.username}@example.com", "--password", "password", @@ -123,7 +123,7 @@ def test_login_cli_wrong_password( load_app.return_value = None result = run( - cli.login_cli, + cli.auth.login_cli, "--instance", "http://localhost:3000", "--email", f"{user.username}@example.com", "--password", "wrong password", @@ -146,7 +146,7 @@ def test_login_cli_wrong_password( def test_logout(delete_user: MagicMock, load_config: MagicMock, run: Run): load_config.return_value = SAMPLE_CONFIG - result = run(cli.logout, "frank@foo.social") + result = run(cli.auth.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")) @@ -156,7 +156,7 @@ def test_logout(delete_user: MagicMock, load_config: MagicMock, run: Run): def test_logout_not_logged_in(load_config: MagicMock, run: Run): load_config.return_value = EMPTY_CONFIG - result = run(cli.logout) + result = run(cli.auth.logout) assert result.exit_code == 1 assert result.stderr.strip() == "Error: You're not logged into any accounts" @@ -165,7 +165,7 @@ def test_logout_not_logged_in(load_config: MagicMock, run: Run): def test_logout_account_not_specified(load_config: MagicMock, run: Run): load_config.return_value = SAMPLE_CONFIG - result = run(cli.logout) + result = run(cli.auth.logout) assert result.exit_code == 1 assert result.stderr.startswith("Error: Specify account to log out") @@ -174,7 +174,7 @@ def test_logout_account_not_specified(load_config: MagicMock, run: Run): def test_logout_account_does_not_exist(load_config: MagicMock, run: Run): load_config.return_value = SAMPLE_CONFIG - result = run(cli.logout, "banana") + result = run(cli.auth.logout, "banana") assert result.exit_code == 1 assert result.stderr.startswith("Error: Account not found") @@ -184,7 +184,7 @@ def test_logout_account_does_not_exist(load_config: MagicMock, run: Run): def test_activate(activate_user: MagicMock, load_config: MagicMock, run: Run): load_config.return_value = SAMPLE_CONFIG - result = run(cli.activate, "frank@foo.social") + result = run(cli.auth.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")) @@ -194,7 +194,7 @@ def test_activate(activate_user: MagicMock, load_config: MagicMock, run: Run): def test_activate_not_logged_in(load_config: MagicMock, run: Run): load_config.return_value = EMPTY_CONFIG - result = run(cli.activate) + result = run(cli.auth.activate) assert result.exit_code == 1 assert result.stderr.strip() == "Error: You're not logged into any accounts" @@ -203,7 +203,7 @@ def test_activate_not_logged_in(load_config: MagicMock, run: Run): def test_activate_account_not_given(load_config: MagicMock, run: Run): load_config.return_value = SAMPLE_CONFIG - result = run(cli.activate) + result = run(cli.auth.activate) assert result.exit_code == 1 assert result.stderr.startswith("Error: Specify account to activate") @@ -212,6 +212,6 @@ def test_activate_account_not_given(load_config: MagicMock, run: Run): def test_activate_invalid_Account(load_config: MagicMock, run: Run): load_config.return_value = SAMPLE_CONFIG - result = run(cli.activate, "banana") + result = run(cli.auth.activate, "banana") assert result.exit_code == 1 assert result.stderr.startswith("Error: Account not found") diff --git a/tests/integration/test_lists.py b/tests/integration/test_lists.py index 171176e..a5cbb1d 100644 --- a/tests/integration/test_lists.py +++ b/tests/integration/test_lists.py @@ -4,83 +4,83 @@ from tests.integration.conftest import register_account def test_lists_empty(run): - result = run(cli.lists) + result = run(cli.lists.lists) assert result.exit_code == 0 assert result.stdout.strip() == "You have no lists defined." def test_list_create_delete(run): - result = run(cli.list_create, "banana") + result = run(cli.lists.list_create, "banana") assert result.exit_code == 0 assert result.stdout.strip() == '✓ List "banana" created.' - result = run(cli.lists) + result = run(cli.lists.lists) assert result.exit_code == 0 assert "banana" in result.stdout - result = run(cli.list_create, "mango") + result = run(cli.lists.list_create, "mango") assert result.exit_code == 0 assert result.stdout.strip() == '✓ List "mango" created.' - result = run(cli.lists) + result = run(cli.lists.lists) assert result.exit_code == 0 assert "banana" in result.stdout assert "mango" in result.stdout - result = run(cli.list_delete, "banana") + result = run(cli.lists.list_delete, "banana") assert result.exit_code == 0 assert result.stdout.strip() == '✓ List "banana" deleted.' - result = run(cli.lists) + result = run(cli.lists.lists) assert result.exit_code == 0 assert "banana" not in result.stdout assert "mango" in result.stdout - result = run(cli.list_delete, "mango") + result = run(cli.lists.list_delete, "mango") assert result.exit_code == 0 assert result.stdout.strip() == '✓ List "mango" deleted.' - result = run(cli.lists) + result = run(cli.lists.lists) assert result.exit_code == 0 assert result.stdout.strip() == "You have no lists defined." - result = run(cli.list_delete, "mango") + result = run(cli.lists.list_delete, "mango") assert result.exit_code == 1 assert result.stderr.strip() == "Error: List not found" def test_list_add_remove(run, app): acc = register_account(app) - run(cli.list_create, "foo") + run(cli.lists.list_create, "foo") - result = run(cli.list_add, "foo", acc.username) + result = run(cli.lists.list_add, "foo", acc.username) assert result.exit_code == 1 assert result.stderr.strip() == f"Error: You must follow @{acc.username} before adding this account to a list." - run(cli.follow, acc.username) + run(cli.accounts.follow, acc.username) - result = run(cli.list_add, "foo", acc.username) + result = run(cli.lists.list_add, "foo", acc.username) assert result.exit_code == 0 assert result.stdout.strip() == f'✓ Added account "{acc.username}"' - result = run(cli.list_accounts, "foo") + result = run(cli.lists.list_accounts, "foo") assert result.exit_code == 0 assert acc.username in result.stdout # Account doesn't exist - result = run(cli.list_add, "foo", "does_not_exist") + result = run(cli.lists.list_add, "foo", "does_not_exist") assert result.exit_code == 1 assert result.stderr.strip() == "Error: Account not found" # List doesn't exist - result = run(cli.list_add, "does_not_exist", acc.username) + result = run(cli.lists.list_add, "does_not_exist", acc.username) assert result.exit_code == 1 assert result.stderr.strip() == "Error: List not found" - result = run(cli.list_remove, "foo", acc.username) + result = run(cli.lists.list_remove, "foo", acc.username) assert result.exit_code == 0 assert result.stdout.strip() == f'✓ Removed account "{acc.username}"' - result = run(cli.list_accounts, "foo") + result = run(cli.lists.list_accounts, "foo") assert result.exit_code == 0 assert result.stdout.strip() == "This list has no accounts." diff --git a/tests/integration/test_post.py b/tests/integration/test_post.py index bf3f8f4..13226b3 100644 --- a/tests/integration/test_post.py +++ b/tests/integration/test_post.py @@ -12,7 +12,7 @@ from unittest import mock def test_post(app, user, run): text = "i wish i was a #lumberjack" - result = run(cli.post, text) + result = run(cli.post.post, text) assert result.exit_code == 0 status_id = posted_status_id(result.stdout) @@ -31,14 +31,14 @@ def test_post(app, user, run): def test_post_no_text(run): - result = run(cli.post) + result = run(cli.post.post) assert result.exit_code == 1 assert result.stderr.strip() == "Error: You must specify either text or media to post." def test_post_json(run): content = "i wish i was a #lumberjack" - result = run(cli.post, content, "--json") + result = run(cli.post.post, content, "--json") assert result.exit_code == 0 status = json.loads(result.stdout) @@ -51,7 +51,7 @@ def test_post_json(run): def test_post_visibility(app, user, run): for visibility in ["public", "unlisted", "private", "direct"]: - result = run(cli.post, "foo", "--visibility", visibility) + result = run(cli.post.post, "foo", "--visibility", visibility) assert result.exit_code == 0 status_id = posted_status_id(result.stdout) @@ -63,7 +63,7 @@ def test_post_scheduled_at(app, user, run): text = str(uuid.uuid4()) scheduled_at = datetime.now(timezone.utc).replace(microsecond=0) + timedelta(minutes=10) - result = run(cli.post, text, "--scheduled-at", scheduled_at.isoformat()) + result = run(cli.post.post, text, "--scheduled-at", scheduled_at.isoformat()) assert result.exit_code == 0 assert "Toot scheduled for" in result.stdout @@ -74,7 +74,7 @@ def test_post_scheduled_at(app, user, run): def test_post_scheduled_at_error(run): - result = run(cli.post, "foo", "--scheduled-at", "banana") + result = run(cli.post.post, "foo", "--scheduled-at", "banana") assert result.exit_code == 1 # Stupid error returned by mastodon assert result.stderr.strip() == "Error: Record invalid" @@ -96,7 +96,7 @@ def test_post_scheduled_in(app, user, run): datetimes = [] for scheduled_in, delta in variants: - result = run(cli.post, text, "--scheduled-in", scheduled_in) + result = run(cli.post.post, text, "--scheduled-in", scheduled_in) assert result.exit_code == 0 dttm = datetime.utcnow() + delta @@ -115,13 +115,13 @@ def test_post_scheduled_in(app, user, run): def test_post_scheduled_in_invalid_duration(run): - result = run(cli.post, "foo", "--scheduled-in", "banana") + result = run(cli.post.post, "foo", "--scheduled-in", "banana") assert result.exit_code == 2 assert "Invalid duration: banana" in result.stderr def test_post_scheduled_in_empty_duration(run): - result = run(cli.post, "foo", "--scheduled-in", "0m") + result = run(cli.post.post, "foo", "--scheduled-in", "0m") assert result.exit_code == 2 assert "Empty duration" in result.stderr @@ -130,7 +130,7 @@ def test_post_poll(app, user, run): text = str(uuid.uuid4()) result = run( - cli.post, text, + cli.post.post, text, "--poll-option", "foo", "--poll-option", "bar", "--poll-option", "baz", @@ -161,7 +161,7 @@ def test_post_poll_multiple(app, user, run): text = str(uuid.uuid4()) result = run( - cli.post, text, + cli.post.post, text, "--poll-option", "foo", "--poll-option", "bar", "--poll-multiple" @@ -177,7 +177,7 @@ def test_post_poll_expires_in(app, user, run): text = str(uuid.uuid4()) result = run( - cli.post, text, + cli.post.post, text, "--poll-option", "foo", "--poll-option", "bar", "--poll-expires-in", "8h", @@ -197,7 +197,7 @@ def test_post_poll_hide_totals(app, user, run): text = str(uuid.uuid4()) result = run( - cli.post, text, + cli.post.post, text, "--poll-option", "foo", "--poll-option", "bar", "--poll-hide-totals" @@ -216,14 +216,14 @@ def test_post_poll_hide_totals(app, user, run): def test_post_language(app, user, run): - result = run(cli.post, "test", "--language", "hr") + result = run(cli.post.post, "test", "--language", "hr") assert result.exit_code == 0 status_id = posted_status_id(result.stdout) status = api.fetch_status(app, user, status_id).json() assert status["language"] == "hr" - result = run(cli.post, "test", "--language", "zh") + result = run(cli.post.post, "test", "--language", "zh") assert result.exit_code == 0 status_id = posted_status_id(result.stdout) @@ -232,7 +232,7 @@ def test_post_language(app, user, run): def test_post_language_error(run): - result = run(cli.post, "test", "--language", "banana") + result = run(cli.post.post, "test", "--language", "banana") assert result.exit_code == 2 assert "Language should be a two letter abbreviation." in result.stderr @@ -242,7 +242,7 @@ def test_media_thumbnail(app, user, run): thumbnail_path = path.join(ASSETS_DIR, "test1.png") result = run( - cli.post, + cli.post.post, "--media", video_path, "--thumbnail", thumbnail_path, "--description", "foo", @@ -276,7 +276,7 @@ def test_media_attachments(app, user, run): path4 = path.join(ASSETS_DIR, "test4.png") result = run( - cli.post, + cli.post.post, "--media", path1, "--media", path2, "--media", path3, @@ -309,7 +309,7 @@ def test_media_attachments(app, user, run): def test_too_many_media(run): m = path.join(ASSETS_DIR, "test1.png") - result = run(cli.post, "-m", m, "-m", m, "-m", m, "-m", m, "-m", m) + result = run(cli.post.post, "-m", m, "-m", m, "-m", m, "-m", m, "-m", m) assert result.exit_code == 1 assert result.stderr.strip() == "Error: Cannot attach more than 4 files." @@ -323,7 +323,7 @@ def test_media_attachment_without_text(mock_read, mock_ml, app, user, run): media_path = path.join(ASSETS_DIR, "test1.png") - result = run(cli.post, "--media", media_path) + result = run(cli.post.post, "--media", media_path) assert result.exit_code == 0 status_id = posted_status_id(result.stdout) @@ -342,7 +342,7 @@ def test_media_attachment_without_text(mock_read, mock_ml, app, user, run): def test_reply_thread(app, user, friend, run): status = api.post_status(app, friend, "This is the status").json() - result = run(cli.post, "--reply-to", status["id"], "This is the reply") + result = run(cli.post.post, "--reply-to", status["id"], "This is the reply") assert result.exit_code == 0 status_id = posted_status_id(result.stdout) @@ -350,7 +350,7 @@ def test_reply_thread(app, user, friend, run): assert reply["in_reply_to_id"] == status["id"] - result = run(cli.thread, status["id"]) + result = run(cli.read.thread, status["id"]) assert result.exit_code == 0 [s1, s2] = [s.strip() for s in re.split(r"─+", result.stdout) if s.strip()] diff --git a/tests/integration/test_read.py b/tests/integration/test_read.py index 6bd2bf0..5c9e4bb 100644 --- a/tests/integration/test_read.py +++ b/tests/integration/test_read.py @@ -8,7 +8,7 @@ from uuid import uuid4 def test_instance_default(app, run): - result = run(cli.instance) + result = run(cli.read.instance) assert result.exit_code == 0 assert "Mastodon" in result.stdout @@ -17,7 +17,7 @@ def test_instance_default(app, run): def test_instance_with_url(app, run): - result = run(cli.instance, TOOT_TEST_BASE_URL) + result = run(cli.read.instance, TOOT_TEST_BASE_URL) assert result.exit_code == 0 assert "Mastodon" in result.stdout @@ -26,7 +26,7 @@ def test_instance_with_url(app, run): def test_instance_json(app, run): - result = run(cli.instance, "--json") + result = run(cli.read.instance, "--json") assert result.exit_code == 0 data = json.loads(result.stdout) @@ -36,7 +36,7 @@ def test_instance_json(app, run): def test_instance_anon(app, run_anon, base_url): - result = run_anon(cli.instance, base_url) + result = run_anon(cli.read.instance, base_url) assert result.exit_code == 0 assert "Mastodon" in result.stdout @@ -44,19 +44,19 @@ def test_instance_anon(app, run_anon, base_url): assert "running Mastodon" in result.stdout # Need to specify the instance name when running anon - result = run_anon(cli.instance) + result = run_anon(cli.read.instance) assert result.exit_code == 1 assert result.stderr == "Error: Please specify an instance.\n" def test_whoami(user, run): - result = run(cli.whoami) + result = run(cli.read.whoami) assert result.exit_code == 0 assert f"@{user.username}" in result.stdout def test_whoami_json(user, run): - result = run(cli.whoami, "--json") + result = run(cli.read.whoami, "--json") assert result.exit_code == 0 data = json.loads(result.stdout) @@ -74,13 +74,13 @@ def test_whois(app, friend, run): ] for username in variants: - result = run(cli.whois, username) + result = run(cli.read.whois, username) assert result.exit_code == 0 assert f"@{friend.username}" in result.stdout def test_whois_json(app, friend, run): - result = run(cli.whois, friend.username, "--json") + result = run(cli.read.whois, friend.username, "--json") assert result.exit_code == 0 data = json.loads(result.stdout) @@ -90,13 +90,13 @@ def test_whois_json(app, friend, run): def test_search_account(friend, run): - result = run(cli.search, friend.username) + result = run(cli.read.search, friend.username) assert result.exit_code == 0 assert result.stdout.strip() == f"Accounts:\n* @{friend.username}" def test_search_account_json(friend, run): - result = run(cli.search, friend.username, "--json") + result = run(cli.read.search, friend.username, "--json") assert result.exit_code == 0 data = json.loads(result.stdout) @@ -109,7 +109,7 @@ def test_search_hashtag(app, user, run): api.post_status(app, user, "#hashtag_y") api.post_status(app, user, "#hashtag_z") - result = run(cli.search, "#hashtag") + result = run(cli.read.search, "#hashtag") assert result.exit_code == 0 assert result.stdout.strip() == "Hashtags:\n#hashtag_x, #hashtag_y, #hashtag_z" @@ -119,7 +119,7 @@ def test_search_hashtag_json(app, user, run): api.post_status(app, user, "#hashtag_y") api.post_status(app, user, "#hashtag_z") - result = run(cli.search, "#hashtag", "--json") + result = run(cli.read.search, "#hashtag", "--json") assert result.exit_code == 0 data = json.loads(result.stdout) @@ -134,7 +134,7 @@ def test_status(app, user, run): uuid = str(uuid4()) status_id = api.post_status(app, user, uuid).json()["id"] - result = run(cli.status, status_id) + result = run(cli.read.status, status_id) assert result.exit_code == 0 out = result.stdout.strip() @@ -147,7 +147,7 @@ def test_status_json(app, user, run): uuid = str(uuid4()) status_id = api.post_status(app, user, uuid).json()["id"] - result = run(cli.status, status_id, "--json") + result = run(cli.read.status, status_id, "--json") assert result.exit_code == 0 status = from_dict(Status, json.loads(result.stdout)) @@ -166,7 +166,7 @@ def test_thread(app, user, run): s3 = api.post_status(app, user, uuid3, in_reply_to_id=s2["id"]).json() for status in [s1, s2, s3]: - result = run(cli.thread, status["id"]) + result = run(cli.read.thread, status["id"]) assert result.exit_code == 0 bits = re.split(r"─+", result.stdout.strip()) @@ -192,7 +192,7 @@ def test_thread_json(app, user, run): s2 = api.post_status(app, user, uuid2, in_reply_to_id=s1["id"]).json() s3 = api.post_status(app, user, uuid3, in_reply_to_id=s2["id"]).json() - result = run(cli.thread, s2["id"], "--json") + result = run(cli.read.thread, s2["id"], "--json") assert result.exit_code == 0 result = json.loads(result.stdout) diff --git a/tests/integration/test_status.py b/tests/integration/test_status.py index 1e88ab0..6f9a2a4 100644 --- a/tests/integration/test_status.py +++ b/tests/integration/test_status.py @@ -9,7 +9,7 @@ from toot.exceptions import NotFoundError def test_delete(app, user, run): status = api.post_status(app, user, "foo").json() - result = run(cli.delete, status["id"]) + result = run(cli.statuses.delete, status["id"]) assert result.exit_code == 0 assert result.stdout.strip() == "✓ Status deleted" @@ -20,7 +20,7 @@ def test_delete(app, user, run): def test_delete_json(app, user, run): status = api.post_status(app, user, "foo").json() - result = run(cli.delete, status["id"], "--json") + result = run(cli.statuses.delete, status["id"], "--json") assert result.exit_code == 0 out = result.stdout @@ -35,14 +35,14 @@ def test_favourite(app, user, run): status = api.post_status(app, user, "foo").json() assert not status["favourited"] - result = run(cli.favourite, status["id"]) + result = run(cli.statuses.favourite, status["id"]) assert result.exit_code == 0 assert result.stdout.strip() == "✓ Status favourited" status = api.fetch_status(app, user, status["id"]).json() assert status["favourited"] - result = run(cli.unfavourite, status["id"]) + result = run(cli.statuses.unfavourite, status["id"]) assert result.exit_code == 0 assert result.stdout.strip() == "✓ Status unfavourited" @@ -57,14 +57,14 @@ def test_favourite_json(app, user, run): status = api.post_status(app, user, "foo").json() assert not status["favourited"] - result = run(cli.favourite, status["id"], "--json") + result = run(cli.statuses.favourite, status["id"], "--json") assert result.exit_code == 0 result = json.loads(result.stdout) assert result["id"] == status["id"] assert result["favourited"] is True - result = run(cli.unfavourite, status["id"], "--json") + result = run(cli.statuses.unfavourite, status["id"], "--json") assert result.exit_code == 0 result = json.loads(result.stdout) @@ -76,22 +76,22 @@ def test_reblog(app, user, run): status = api.post_status(app, user, "foo").json() assert not status["reblogged"] - result = run(cli.reblogged_by, status["id"]) + result = run(cli.statuses.reblogged_by, status["id"]) assert result.exit_code == 0 assert result.stdout.strip() == "This status is not reblogged by anyone" - result = run(cli.reblog, status["id"]) + result = run(cli.statuses.reblog, status["id"]) assert result.exit_code == 0 assert result.stdout.strip() == "✓ Status reblogged" status = api.fetch_status(app, user, status["id"]).json() assert status["reblogged"] - result = run(cli.reblogged_by, status["id"]) + result = run(cli.statuses.reblogged_by, status["id"]) assert result.exit_code == 0 assert user.username in result.stdout - result = run(cli.unreblog, status["id"]) + result = run(cli.statuses.unreblog, status["id"]) assert result.exit_code == 0 assert result.stdout.strip() == "✓ Status unreblogged" @@ -103,20 +103,20 @@ def test_reblog_json(app, user, run): status = api.post_status(app, user, "foo").json() assert not status["reblogged"] - result = run(cli.reblog, status["id"], "--json") + result = run(cli.statuses.reblog, status["id"], "--json") assert result.exit_code == 0 result = json.loads(result.stdout) assert result["reblogged"] is True assert result["reblog"]["id"] == status["id"] - result = run(cli.reblogged_by, status["id"], "--json") + result = run(cli.statuses.reblogged_by, status["id"], "--json") assert result.exit_code == 0 [reblog] = json.loads(result.stdout) assert reblog["acct"] == user.username - result = run(cli.unreblog, status["id"], "--json") + result = run(cli.statuses.unreblog, status["id"], "--json") assert result.exit_code == 0 result = json.loads(result.stdout) @@ -128,14 +128,14 @@ def test_pin(app, user, run): status = api.post_status(app, user, "foo").json() assert not status["pinned"] - result = run(cli.pin, status["id"]) + result = run(cli.statuses.pin, status["id"]) assert result.exit_code == 0 assert result.stdout.strip() == "✓ Status pinned" status = api.fetch_status(app, user, status["id"]).json() assert status["pinned"] - result = run(cli.unpin, status["id"]) + result = run(cli.statuses.unpin, status["id"]) assert result.exit_code == 0 assert result.stdout.strip() == "✓ Status unpinned" @@ -147,14 +147,14 @@ def test_pin_json(app, user, run): status = api.post_status(app, user, "foo").json() assert not status["pinned"] - result = run(cli.pin, status["id"], "--json") + result = run(cli.statuses.pin, status["id"], "--json") assert result.exit_code == 0 result = json.loads(result.stdout) assert result["pinned"] is True assert result["id"] == status["id"] - result = run(cli.unpin, status["id"], "--json") + result = run(cli.statuses.unpin, status["id"], "--json") assert result.exit_code == 0 result = json.loads(result.stdout) @@ -166,14 +166,14 @@ def test_bookmark(app, user, run): status = api.post_status(app, user, "foo").json() assert not status["bookmarked"] - result = run(cli.bookmark, status["id"]) + result = run(cli.statuses.bookmark, status["id"]) assert result.exit_code == 0 assert result.stdout.strip() == "✓ Status bookmarked" status = api.fetch_status(app, user, status["id"]).json() assert status["bookmarked"] - result = run(cli.unbookmark, status["id"]) + result = run(cli.statuses.unbookmark, status["id"]) assert result.exit_code == 0 assert result.stdout.strip() == "✓ Status unbookmarked" @@ -185,14 +185,14 @@ def test_bookmark_json(app, user, run): status = api.post_status(app, user, "foo").json() assert not status["bookmarked"] - result = run(cli.bookmark, status["id"], "--json") + result = run(cli.statuses.bookmark, status["id"], "--json") assert result.exit_code == 0 result = json.loads(result.stdout) assert result["id"] == status["id"] assert result["bookmarked"] is True - result = run(cli.unbookmark, status["id"], "--json") + result = run(cli.statuses.unbookmark, status["id"], "--json") assert result.exit_code == 0 result = json.loads(result.stdout) diff --git a/tests/integration/test_tags.py b/tests/integration/test_tags.py index eaddaba..9df84ce 100644 --- a/tests/integration/test_tags.py +++ b/tests/integration/test_tags.py @@ -6,63 +6,63 @@ from toot.entities import FeaturedTag, Tag, from_dict, from_dict_list def test_tags(run): - result = run(cli.tags, "followed") + result = run(cli.tags.tags, "followed") assert result.exit_code == 0 assert result.stdout.strip() == "You're not following any hashtags" - result = run(cli.tags, "follow", "foo") + result = run(cli.tags.tags, "follow", "foo") assert result.exit_code == 0 assert result.stdout.strip() == "✓ You are now following #foo" - result = run(cli.tags, "followed") + result = run(cli.tags.tags, "followed") assert result.exit_code == 0 assert _find_tags(result.stdout) == ["#foo"] - result = run(cli.tags, "follow", "bar") + result = run(cli.tags.tags, "follow", "bar") assert result.exit_code == 0 assert result.stdout.strip() == "✓ You are now following #bar" - result = run(cli.tags, "followed") + result = run(cli.tags.tags, "followed") assert result.exit_code == 0 assert _find_tags(result.stdout) == ["#bar", "#foo"] - result = run(cli.tags, "unfollow", "foo") + result = run(cli.tags.tags, "unfollow", "foo") assert result.exit_code == 0 assert result.stdout.strip() == "✓ You are no longer following #foo" - result = run(cli.tags, "followed") + result = run(cli.tags.tags, "followed") assert result.exit_code == 0 assert _find_tags(result.stdout) == ["#bar"] - result = run(cli.tags, "unfollow", "bar") + result = run(cli.tags.tags, "unfollow", "bar") assert result.exit_code == 0 assert result.stdout.strip() == "✓ You are no longer following #bar" - result = run(cli.tags, "followed") + result = run(cli.tags.tags, "followed") assert result.exit_code == 0 assert result.stdout.strip() == "You're not following any hashtags" def test_tags_json(run_json): - result = run_json(cli.tags, "followed", "--json") + result = run_json(cli.tags.tags, "followed", "--json") assert result == [] - result = run_json(cli.tags, "follow", "foo", "--json") + result = run_json(cli.tags.tags, "follow", "foo", "--json") tag = from_dict(Tag, result) assert tag.name == "foo" assert tag.following is True - result = run_json(cli.tags, "followed", "--json") + result = run_json(cli.tags.tags, "followed", "--json") [tag] = from_dict_list(Tag, result) assert tag.name == "foo" assert tag.following is True - result = run_json(cli.tags, "follow", "bar", "--json") + result = run_json(cli.tags.tags, "follow", "bar", "--json") tag = from_dict(Tag, result) assert tag.name == "bar" assert tag.following is True - result = run_json(cli.tags, "followed", "--json") + result = run_json(cli.tags.tags, "followed", "--json") tags = from_dict_list(Tag, result) [bar, foo] = sorted(tags, key=lambda t: t.name) assert foo.name == "foo" @@ -70,47 +70,47 @@ def test_tags_json(run_json): assert bar.name == "bar" assert bar.following is True - result = run_json(cli.tags, "unfollow", "foo", "--json") + result = run_json(cli.tags.tags, "unfollow", "foo", "--json") tag = from_dict(Tag, result) assert tag.name == "foo" assert tag.following is False - result = run_json(cli.tags, "unfollow", "bar", "--json") + result = run_json(cli.tags.tags, "unfollow", "bar", "--json") tag = from_dict(Tag, result) assert tag.name == "bar" assert tag.following is False - result = run_json(cli.tags, "followed", "--json") + result = run_json(cli.tags.tags, "followed", "--json") assert result == [] def test_tags_featured(run, app, user): - result = run(cli.tags, "featured") + result = run(cli.tags.tags, "featured") assert result.exit_code == 0 assert result.stdout.strip() == "You don't have any featured hashtags" - result = run(cli.tags, "feature", "foo") + result = run(cli.tags.tags, "feature", "foo") assert result.exit_code == 0 assert result.stdout.strip() == "✓ Tag #foo is now featured" - result = run(cli.tags, "featured") + result = run(cli.tags.tags, "featured") assert result.exit_code == 0 assert _find_tags(result.stdout) == ["#foo"] - result = run(cli.tags, "feature", "bar") + result = run(cli.tags.tags, "feature", "bar") assert result.exit_code == 0 assert result.stdout.strip() == "✓ Tag #bar is now featured" - result = run(cli.tags, "featured") + result = run(cli.tags.tags, "featured") assert result.exit_code == 0 assert _find_tags(result.stdout) == ["#bar", "#foo"] # Unfeature by Name - result = run(cli.tags, "unfeature", "foo") + result = run(cli.tags.tags, "unfeature", "foo") assert result.exit_code == 0 assert result.stdout.strip() == "✓ Tag #foo is no longer featured" - result = run(cli.tags, "featured") + result = run(cli.tags.tags, "featured") assert result.exit_code == 0 assert _find_tags(result.stdout) == ["#bar"] @@ -118,44 +118,44 @@ def test_tags_featured(run, app, user): tag = api.find_featured_tag(app, user, "bar") assert tag is not None - result = run(cli.tags, "unfeature", tag["id"]) + result = run(cli.tags.tags, "unfeature", tag["id"]) assert result.exit_code == 0 assert result.stdout.strip() == "✓ Tag #bar is no longer featured" - result = run(cli.tags, "featured") + result = run(cli.tags.tags, "featured") assert result.exit_code == 0 assert result.stdout.strip() == "You don't have any featured hashtags" def test_tags_featured_json(run_json): - result = run_json(cli.tags, "featured", "--json") + result = run_json(cli.tags.tags, "featured", "--json") assert result == [] - result = run_json(cli.tags, "feature", "foo", "--json") + result = run_json(cli.tags.tags, "feature", "foo", "--json") tag = from_dict(FeaturedTag, result) assert tag.name == "foo" - result = run_json(cli.tags, "featured", "--json") + result = run_json(cli.tags.tags, "featured", "--json") [tag] = from_dict_list(FeaturedTag, result) assert tag.name == "foo" - result = run_json(cli.tags, "feature", "bar", "--json") + result = run_json(cli.tags.tags, "feature", "bar", "--json") tag = from_dict(FeaturedTag, result) assert tag.name == "bar" - result = run_json(cli.tags, "featured", "--json") + result = run_json(cli.tags.tags, "featured", "--json") tags = from_dict_list(FeaturedTag, result) [bar, foo] = sorted(tags, key=lambda t: t.name) assert foo.name == "foo" assert bar.name == "bar" - result = run_json(cli.tags, "unfeature", "foo", "--json") + result = run_json(cli.tags.tags, "unfeature", "foo", "--json") assert result == {} - result = run_json(cli.tags, "unfeature", "bar", "--json") + result = run_json(cli.tags.tags, "unfeature", "bar", "--json") assert result == {} - result = run_json(cli.tags, "featured", "--json") + result = run_json(cli.tags.tags, "featured", "--json") assert result == [] diff --git a/tests/integration/test_timelines.py b/tests/integration/test_timelines.py index 70aa676..8823f69 100644 --- a/tests/integration/test_timelines.py +++ b/tests/integration/test_timelines.py @@ -45,68 +45,68 @@ def test_timelines(app, user, other_user, friend_user, friend_list, run): sleep(1) # Home timeline - result = run(cli.timeline) + result = run(cli.timelines.timeline) assert result.exit_code == 0 assert status1.id in result.stdout assert status2.id not in result.stdout assert status3.id in result.stdout # Public timeline - result = run(cli.timeline, "--public") + result = run(cli.timelines.timeline, "--public") assert result.exit_code == 0 assert status1.id in result.stdout assert status2.id in result.stdout assert status3.id in result.stdout # Anon public timeline - result = run(cli.timeline, "--instance", TOOT_TEST_BASE_URL, "--public") + result = run(cli.timelines.timeline, "--instance", TOOT_TEST_BASE_URL, "--public") assert result.exit_code == 0 assert status1.id in result.stdout assert status2.id in result.stdout assert status3.id in result.stdout # Tag timeline - result = run(cli.timeline, "--tag", "foo") + result = run(cli.timelines.timeline, "--tag", "foo") assert result.exit_code == 0 assert status1.id in result.stdout assert status2.id not in result.stdout assert status3.id in result.stdout - result = run(cli.timeline, "--tag", "bar") + result = run(cli.timelines.timeline, "--tag", "bar") assert result.exit_code == 0 assert status1.id not in result.stdout assert status2.id in result.stdout assert status3.id in result.stdout # Anon tag timeline - result = run(cli.timeline, "--instance", TOOT_TEST_BASE_URL, "--tag", "foo") + result = run(cli.timelines.timeline, "--instance", TOOT_TEST_BASE_URL, "--tag", "foo") assert result.exit_code == 0 assert status1.id in result.stdout assert status2.id not in result.stdout assert status3.id in result.stdout # List timeline (by list name) - result = run(cli.timeline, "--list", friend_list["title"]) + result = run(cli.timelines.timeline, "--list", friend_list["title"]) assert result.exit_code == 0 assert status1.id not in result.stdout assert status2.id not in result.stdout assert status3.id in result.stdout # List timeline (by list ID) - result = run(cli.timeline, "--list", friend_list["id"]) + result = run(cli.timelines.timeline, "--list", friend_list["id"]) assert result.exit_code == 0 assert status1.id not in result.stdout assert status2.id not in result.stdout assert status3.id in result.stdout # Account timeline - result = run(cli.timeline, "--account", friend_user.username) + result = run(cli.timelines.timeline, "--account", friend_user.username) assert result.exit_code == 0 assert status1.id not in result.stdout assert status2.id not in result.stdout assert status3.id in result.stdout - result = run(cli.timeline, "--account", other_user.username) + result = run(cli.timelines.timeline, "--account", other_user.username) assert result.exit_code == 0 assert status1.id not in result.stdout assert status2.id in result.stdout @@ -115,25 +115,25 @@ def test_timelines(app, user, other_user, friend_user, friend_list, run): def test_empty_timeline(app, run_as): user = register_account(app) - result = run_as(user, cli.timeline) + result = run_as(user, cli.timelines.timeline) assert result.exit_code == 0 assert result.stdout.strip() == "─" * 80 def test_timeline_cant_combine_timelines(run): - result = run(cli.timeline, "--tag", "foo", "--account", "bar") + result = run(cli.timelines.timeline, "--tag", "foo", "--account", "bar") assert result.exit_code == 1 assert result.stderr.strip() == "Error: Only one of --public, --tag, --account, or --list can be used at one time." def test_timeline_local_needs_public_or_tag(run): - result = run(cli.timeline, "--local") + result = run(cli.timelines.timeline, "--local") assert result.exit_code == 1 assert result.stderr.strip() == "Error: The --local option is only valid alongside --public or --tag." def test_timeline_instance_needs_public_or_tag(run): - result = run(cli.timeline, "--instance", TOOT_TEST_BASE_URL) + result = run(cli.timelines.timeline, "--instance", TOOT_TEST_BASE_URL) assert result.exit_code == 1 assert result.stderr.strip() == "Error: The --instance option is only valid alongside --public or --tag." @@ -145,14 +145,14 @@ def test_bookmarks(app, user, run): api.bookmark(app, user, status1.id) api.bookmark(app, user, status2.id) - result = run(cli.bookmarks) + result = run(cli.timelines.bookmarks) assert result.exit_code == 0 assert status1.id in result.stdout assert status2.id in result.stdout assert result.stdout.find(status1.id) > result.stdout.find(status2.id) - result = run(cli.bookmarks, "--reverse") + result = run(cli.timelines.bookmarks, "--reverse") assert result.exit_code == 0 assert status1.id in result.stdout assert status2.id in result.stdout @@ -160,7 +160,7 @@ def test_bookmarks(app, user, run): def test_notifications(app, user, other_user, run): - result = run(cli.notifications) + result = run(cli.timelines.notifications) assert result.exit_code == 0 assert result.stdout.strip() == "You have no notifications" @@ -168,13 +168,13 @@ def test_notifications(app, user, other_user, run): status = _post_status(app, other_user, text) sleep(0.5) # grr - result = run(cli.notifications) + result = run(cli.timelines.notifications) assert result.exit_code == 0 assert f"@{other_user.username} mentioned you" in result.stdout assert status.id in result.stdout assert text in result.stdout - result = run(cli.notifications, "--mentions") + result = run(cli.timelines.notifications, "--mentions") assert result.exit_code == 0 assert f"@{other_user.username} mentioned you" in result.stdout assert status.id in result.stdout @@ -182,12 +182,12 @@ def test_notifications(app, user, other_user, run): def test_notifications_follow(app, user, friend_user, run_as): - result = run_as(friend_user, cli.notifications) + result = run_as(friend_user, cli.timelines.notifications) assert result.exit_code == 0 assert f"@{user.username} now follows you" in result.stdout - result = run_as(friend_user, cli.notifications, "--mentions") + result = run_as(friend_user, cli.timelines.notifications, "--mentions") assert result.exit_code == 0 assert "now follows you" not in result.stdout diff --git a/tests/integration/test_update_account.py b/tests/integration/test_update_account.py index 83343d7..6c1dd60 100644 --- a/tests/integration/test_update_account.py +++ b/tests/integration/test_update_account.py @@ -6,7 +6,7 @@ from toot.utils import get_text def test_update_account_no_options(run): - result = run(cli.update_account) + result = run(cli.accounts.update_account) assert result.exit_code == 1 assert result.stderr.strip() == "Error: Please specify at least one option to update the account" @@ -14,7 +14,7 @@ def test_update_account_no_options(run): def test_update_account_display_name(run, app, user): name = str(uuid4())[:10] - result = run(cli.update_account, "--display-name", name) + result = run(cli.accounts.update_account, "--display-name", name) assert result.exit_code == 0 assert result.stdout.strip() == "✓ Account updated" @@ -24,7 +24,7 @@ def test_update_account_display_name(run, app, user): def test_update_account_json(run_json, app, user): name = str(uuid4())[:10] - out = run_json(cli.update_account, "--display-name", name, "--json") + out = run_json(cli.accounts.update_account, "--display-name", name, "--json") account = from_dict(Account, out) assert account.acct == user.username assert account.display_name == name @@ -34,7 +34,7 @@ def test_update_account_note(run, app, user): note = ("It's 106 miles to Chicago, we got a full tank of gas, half a pack " "of cigarettes, it's dark... and we're wearing sunglasses.") - result = run(cli.update_account, "--note", note) + result = run(cli.accounts.update_account, "--note", note) assert result.exit_code == 0 assert result.stdout.strip() == "✓ Account updated" @@ -43,7 +43,7 @@ def test_update_account_note(run, app, user): def test_update_account_language(run, app, user): - result = run(cli.update_account, "--language", "hr") + result = run(cli.accounts.update_account, "--language", "hr") assert result.exit_code == 0 assert result.stdout.strip() == "✓ Account updated" @@ -52,7 +52,7 @@ def test_update_account_language(run, app, user): def test_update_account_privacy(run, app, user): - result = run(cli.update_account, "--privacy", "private") + result = run(cli.accounts.update_account, "--privacy", "private") assert result.exit_code == 0 assert result.stdout.strip() == "✓ Account updated" @@ -64,7 +64,7 @@ def test_update_account_avatar(run, app, user): account = api.verify_credentials(app, user).json() old_value = account["avatar"] - result = run(cli.update_account, "--avatar", TRUMPET) + result = run(cli.accounts.update_account, "--avatar", TRUMPET) assert result.exit_code == 0 assert result.stdout.strip() == "✓ Account updated" @@ -76,7 +76,7 @@ def test_update_account_header(run, app, user): account = api.verify_credentials(app, user).json() old_value = account["header"] - result = run(cli.update_account, "--header", TRUMPET) + result = run(cli.accounts.update_account, "--header", TRUMPET) assert result.exit_code == 0 assert result.stdout.strip() == "✓ Account updated" @@ -85,14 +85,14 @@ def test_update_account_header(run, app, user): def test_update_account_locked(run, app, user): - result = run(cli.update_account, "--locked") + result = run(cli.accounts.update_account, "--locked") assert result.exit_code == 0 assert result.stdout.strip() == "✓ Account updated" account = api.verify_credentials(app, user).json() assert account["locked"] is True - result = run(cli.update_account, "--no-locked") + result = run(cli.accounts.update_account, "--no-locked") assert result.exit_code == 0 assert result.stdout.strip() == "✓ Account updated" @@ -101,7 +101,7 @@ def test_update_account_locked(run, app, user): def test_update_account_bot(run, app, user): - result = run(cli.update_account, "--bot") + result = run(cli.accounts.update_account, "--bot") assert result.exit_code == 0 assert result.stdout.strip() == "✓ Account updated" @@ -109,7 +109,7 @@ def test_update_account_bot(run, app, user): account = api.verify_credentials(app, user).json() assert account["bot"] is True - result = run(cli.update_account, "--no-bot") + result = run(cli.accounts.update_account, "--no-bot") assert result.exit_code == 0 assert result.stdout.strip() == "✓ Account updated" @@ -118,14 +118,14 @@ def test_update_account_bot(run, app, user): def test_update_account_discoverable(run, app, user): - result = run(cli.update_account, "--discoverable") + result = run(cli.accounts.update_account, "--discoverable") assert result.exit_code == 0 assert result.stdout.strip() == "✓ Account updated" account = api.verify_credentials(app, user).json() assert account["discoverable"] is True - result = run(cli.update_account, "--no-discoverable") + result = run(cli.accounts.update_account, "--no-discoverable") assert result.exit_code == 0 assert result.stdout.strip() == "✓ Account updated" @@ -134,14 +134,14 @@ def test_update_account_discoverable(run, app, user): def test_update_account_sensitive(run, app, user): - result = run(cli.update_account, "--sensitive") + result = run(cli.accounts.update_account, "--sensitive") assert result.exit_code == 0 assert result.stdout.strip() == "✓ Account updated" account = api.verify_credentials(app, user).json() assert account["source"]["sensitive"] is True - result = run(cli.update_account, "--no-sensitive") + result = run(cli.accounts.update_account, "--no-sensitive") assert result.exit_code == 0 assert result.stdout.strip() == "✓ Account updated" diff --git a/toot/cli/__init__.py b/toot/cli/__init__.py index 2499e0e..6f81aa0 100644 --- a/toot/cli/__init__.py +++ b/toot/cli/__init__.py @@ -1,12 +1,12 @@ # flake8: noqa from toot.cli.base import cli, Context -from toot.cli.accounts import * -from toot.cli.auth import * -from toot.cli.lists import * -from toot.cli.post import * -from toot.cli.read import * -from toot.cli.statuses import * -from toot.cli.tags import * -from toot.cli.timelines import * -from toot.cli.tui import * +from toot.cli import accounts +from toot.cli import auth +from toot.cli import lists +from toot.cli import post +from toot.cli import read +from toot.cli import statuses +from toot.cli import tags +from toot.cli import timelines +from toot.cli import tui From ad7cfd44d4bfc32eb9c702835b336495b9bf54da Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Wed, 13 Dec 2023 15:11:45 +0100 Subject: [PATCH 54/59] Update changelog --- CHANGELOG.md | 25 +++++++++++++++++++++---- changelog.yaml | 12 +++++++++++- docs/changelog.md | 25 +++++++++++++++++++++---- scripts/generate_changelog | 7 +++++++ 4 files changed, 60 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f236c3..eb94777 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,28 @@ Changelog **0.40.0 (TBA)** -* Migrate to `click` for commandline arguments. BC should be mostly preserved, - please report any issues. +This release includes a major rewrite to use +[Click](https://click.palletsprojects.com/) for creating the command line +interface. This allows for some new features like nested commands, setting +parameters via environment variables, and shell completion. See docs for +details. Backward compatibility should be mostly preserved, except for cases +noted below please report any issues. + +* BREAKING: Remove deprecated `--disable-https` option for `login` and + `login_cli`, pass the base URL instead +* BREAKING: Options `--debug`, `--color`, `--quiet` must be specified after + `toot` but before the command +* Enable passing params via environment variables, see: + https://toot.bezdomni.net/environment_variables.html * Add shell completion, see: https://toot.bezdomni.net/shell_completion.html -* Remove deprecated `--disable-https` option for `login` and `login_cli`, pass - the base URL instead +* Add `--json` option to tag commands +* Add `tags info`, `tags featured`, `tags feature`, and `tags unfeature` + commands +* Add `tags followed`, `tags follow`, and `tags unfollow` commands, deprecate + `tags_followed`, `tags_follow`, and `tags tags_unfollow` +* Add `toot --width` option for setting your prefered terminal width +* Add `--media-viewer` and `--colors` options to `toot tui`. These were + previously accessible only via settings. **0.39.0 (2023-11-23)** diff --git a/changelog.yaml b/changelog.yaml index 5713612..6d2539d 100644 --- a/changelog.yaml +++ b/changelog.yaml @@ -1,11 +1,21 @@ 0.40.0: date: TBA + description: | + This release includes a major rewrite to use [Click](https://click.palletsprojects.com/) for + creating the command line interface. This allows for some new features like nested commands, + setting parameters via environment variables, and shell completion. See docs for details. + Backward compatibility should be mostly preserved, except for cases noted below please report + any issues. changes: - "BREAKING: Remove deprecated `--disable-https` option for `login` and `login_cli`, pass the base URL instead" - - "Migrate to `click` for commandline arguments. BC should be mostly preserved, please report any issues." + - "BREAKING: Options `--debug`, `--color`, `--quiet` must be specified after `toot` but before the command" + - "Enable passing params via environment variables, see: https://toot.bezdomni.net/environment_variables.html" - "Add shell completion, see: https://toot.bezdomni.net/shell_completion.html" - "Add `--json` option to tag commands" - "Add `tags info`, `tags featured`, `tags feature`, and `tags unfeature` commands" + - "Add `tags followed`, `tags follow`, and `tags unfollow` commands, deprecate `tags_followed`, `tags_follow`, and `tags tags_unfollow`" + - "Add `toot --width` option for setting your prefered terminal width" + - "Add `--media-viewer` and `--colors` options to `toot tui`. These were previously accessible only via settings." 0.39.0: date: 2023-11-23 diff --git a/docs/changelog.md b/docs/changelog.md index 8f236c3..eb94777 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -5,11 +5,28 @@ Changelog **0.40.0 (TBA)** -* Migrate to `click` for commandline arguments. BC should be mostly preserved, - please report any issues. +This release includes a major rewrite to use +[Click](https://click.palletsprojects.com/) for creating the command line +interface. This allows for some new features like nested commands, setting +parameters via environment variables, and shell completion. See docs for +details. Backward compatibility should be mostly preserved, except for cases +noted below please report any issues. + +* BREAKING: Remove deprecated `--disable-https` option for `login` and + `login_cli`, pass the base URL instead +* BREAKING: Options `--debug`, `--color`, `--quiet` must be specified after + `toot` but before the command +* Enable passing params via environment variables, see: + https://toot.bezdomni.net/environment_variables.html * Add shell completion, see: https://toot.bezdomni.net/shell_completion.html -* Remove deprecated `--disable-https` option for `login` and `login_cli`, pass - the base URL instead +* Add `--json` option to tag commands +* Add `tags info`, `tags featured`, `tags feature`, and `tags unfeature` + commands +* Add `tags followed`, `tags follow`, and `tags unfollow` commands, deprecate + `tags_followed`, `tags_follow`, and `tags tags_unfollow` +* Add `toot --width` option for setting your prefered terminal width +* Add `--media-viewer` and `--colors` options to `toot tui`. These were + previously accessible only via settings. **0.39.0 (2023-11-23)** diff --git a/scripts/generate_changelog b/scripts/generate_changelog index 5e6021e..8aa5d6c 100755 --- a/scripts/generate_changelog +++ b/scripts/generate_changelog @@ -21,6 +21,13 @@ for version in data.keys(): changes = data[version]["changes"] print(f"**{version} ({date})**") print() + + if "description" in data[version]: + description = data[version]["description"].strip() + for line in textwrap.wrap(description, 80): + print(line) + print() + for c in changes: lines = textwrap.wrap(c, 78) initial = True From 7ba2d9cce594087d3bc2c7f77f7f27183f01e93b Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Wed, 13 Dec 2023 15:32:08 +0100 Subject: [PATCH 55/59] Use click echo instead of print --- toot/cli/read.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/toot/cli/read.py b/toot/cli/read.py index 9677c4a..4d5e2d0 100644 --- a/toot/cli/read.py +++ b/toot/cli/read.py @@ -63,7 +63,7 @@ def instance(ctx: Context, instance_url: Optional[str], json: bool): ) if json: - print(response.text) + click.echo(response.text) else: instance = from_dict(Instance, response.json()) print_instance(instance) @@ -78,7 +78,7 @@ def search(ctx: Context, query: str, resolve: bool, json: bool): """Search for users or hashtags""" response = api.search(ctx.app, ctx.user, query, resolve) if json: - print(response.text) + click.echo(response.text) else: print_search_results(response.json()) @@ -91,7 +91,7 @@ def status(ctx: Context, status_id: str, json: bool): """Show a single status""" response = api.fetch_status(ctx.app, ctx.user, status_id) if json: - print(response.text) + click.echo(response.text) else: status = from_dict(Status, response.json()) print_status(status) @@ -105,7 +105,7 @@ def thread(ctx: Context, status_id: str, json: bool): """Show thread for a toot.""" context_response = api.context(ctx.app, ctx.user, status_id) if json: - print(context_response.text) + click.echo(context_response.text) else: toot = api.fetch_status(ctx.app, ctx.user, status_id).json() context = context_response.json() From 164016481d83da7ffae6da4002f389c94bfb1132 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Wed, 13 Dec 2023 16:14:46 +0100 Subject: [PATCH 56/59] Replace lists commands with subcommands --- CHANGELOG.md | 7 +- changelog.yaml | 3 +- docs/changelog.md | 7 +- tests/integration/test_lists.py | 36 ++++---- toot/api.py | 8 -- toot/cli/lists.py | 142 ++++++++++++++++++++++++++++---- 6 files changed, 156 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb94777..6894551 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,8 +22,11 @@ noted below please report any issues. * Add `--json` option to tag commands * Add `tags info`, `tags featured`, `tags feature`, and `tags unfeature` commands -* Add `tags followed`, `tags follow`, and `tags unfollow` commands, deprecate - `tags_followed`, `tags_follow`, and `tags tags_unfollow` +* Add `tags followed`, `tags follow`, and `tags unfollow` sub-commands, + deprecate `tags_followed`, `tags_follow`, and `tags tags_unfollow` +* Add `lists accounts`, `lists add`, `lists create`, `lists delete`, `lists + list`, `lists remove` subcommands, deprecate `lists`, `lists_accounts`, + `lists_add`, `lists_create`, `lists_delete`, `lists_remove` commands. * Add `toot --width` option for setting your prefered terminal width * Add `--media-viewer` and `--colors` options to `toot tui`. These were previously accessible only via settings. diff --git a/changelog.yaml b/changelog.yaml index 6d2539d..cf25955 100644 --- a/changelog.yaml +++ b/changelog.yaml @@ -13,7 +13,8 @@ - "Add shell completion, see: https://toot.bezdomni.net/shell_completion.html" - "Add `--json` option to tag commands" - "Add `tags info`, `tags featured`, `tags feature`, and `tags unfeature` commands" - - "Add `tags followed`, `tags follow`, and `tags unfollow` commands, deprecate `tags_followed`, `tags_follow`, and `tags tags_unfollow`" + - "Add `tags followed`, `tags follow`, and `tags unfollow` sub-commands, deprecate `tags_followed`, `tags_follow`, and `tags tags_unfollow`" + - "Add `lists accounts`, `lists add`, `lists create`, `lists delete`, `lists list`, `lists remove` subcommands, deprecate `lists`, `lists_accounts`, `lists_add`, `lists_create`, `lists_delete`, `lists_remove` commands." - "Add `toot --width` option for setting your prefered terminal width" - "Add `--media-viewer` and `--colors` options to `toot tui`. These were previously accessible only via settings." diff --git a/docs/changelog.md b/docs/changelog.md index eb94777..6894551 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -22,8 +22,11 @@ noted below please report any issues. * Add `--json` option to tag commands * Add `tags info`, `tags featured`, `tags feature`, and `tags unfeature` commands -* Add `tags followed`, `tags follow`, and `tags unfollow` commands, deprecate - `tags_followed`, `tags_follow`, and `tags tags_unfollow` +* Add `tags followed`, `tags follow`, and `tags unfollow` sub-commands, + deprecate `tags_followed`, `tags_follow`, and `tags tags_unfollow` +* Add `lists accounts`, `lists add`, `lists create`, `lists delete`, `lists + list`, `lists remove` subcommands, deprecate `lists`, `lists_accounts`, + `lists_add`, `lists_create`, `lists_delete`, `lists_remove` commands. * Add `toot --width` option for setting your prefered terminal width * Add `--media-viewer` and `--colors` options to `toot tui`. These were previously accessible only via settings. diff --git a/tests/integration/test_lists.py b/tests/integration/test_lists.py index a5cbb1d..21b3f0b 100644 --- a/tests/integration/test_lists.py +++ b/tests/integration/test_lists.py @@ -4,83 +4,83 @@ from tests.integration.conftest import register_account def test_lists_empty(run): - result = run(cli.lists.lists) + result = run(cli.lists.list) assert result.exit_code == 0 assert result.stdout.strip() == "You have no lists defined." def test_list_create_delete(run): - result = run(cli.lists.list_create, "banana") + result = run(cli.lists.create, "banana") assert result.exit_code == 0 assert result.stdout.strip() == '✓ List "banana" created.' - result = run(cli.lists.lists) + result = run(cli.lists.list) assert result.exit_code == 0 assert "banana" in result.stdout - result = run(cli.lists.list_create, "mango") + result = run(cli.lists.create, "mango") assert result.exit_code == 0 assert result.stdout.strip() == '✓ List "mango" created.' - result = run(cli.lists.lists) + result = run(cli.lists.list) assert result.exit_code == 0 assert "banana" in result.stdout assert "mango" in result.stdout - result = run(cli.lists.list_delete, "banana") + result = run(cli.lists.delete, "banana") assert result.exit_code == 0 assert result.stdout.strip() == '✓ List "banana" deleted.' - result = run(cli.lists.lists) + result = run(cli.lists.list) assert result.exit_code == 0 assert "banana" not in result.stdout assert "mango" in result.stdout - result = run(cli.lists.list_delete, "mango") + result = run(cli.lists.delete, "mango") assert result.exit_code == 0 assert result.stdout.strip() == '✓ List "mango" deleted.' - result = run(cli.lists.lists) + result = run(cli.lists.list) assert result.exit_code == 0 assert result.stdout.strip() == "You have no lists defined." - result = run(cli.lists.list_delete, "mango") + result = run(cli.lists.delete, "mango") assert result.exit_code == 1 assert result.stderr.strip() == "Error: List not found" def test_list_add_remove(run, app): acc = register_account(app) - run(cli.lists.list_create, "foo") + run(cli.lists.create, "foo") - result = run(cli.lists.list_add, "foo", acc.username) + result = run(cli.lists.add, "foo", acc.username) assert result.exit_code == 1 assert result.stderr.strip() == f"Error: You must follow @{acc.username} before adding this account to a list." run(cli.accounts.follow, acc.username) - result = run(cli.lists.list_add, "foo", acc.username) + result = run(cli.lists.add, "foo", acc.username) assert result.exit_code == 0 assert result.stdout.strip() == f'✓ Added account "{acc.username}"' - result = run(cli.lists.list_accounts, "foo") + result = run(cli.lists.accounts, "foo") assert result.exit_code == 0 assert acc.username in result.stdout # Account doesn't exist - result = run(cli.lists.list_add, "foo", "does_not_exist") + result = run(cli.lists.add, "foo", "does_not_exist") assert result.exit_code == 1 assert result.stderr.strip() == "Error: Account not found" # List doesn't exist - result = run(cli.lists.list_add, "does_not_exist", acc.username) + result = run(cli.lists.add, "does_not_exist", acc.username) assert result.exit_code == 1 assert result.stderr.strip() == "Error: List not found" - result = run(cli.lists.list_remove, "foo", acc.username) + result = run(cli.lists.remove, "foo", acc.username) assert result.exit_code == 0 assert result.stdout.strip() == f'✓ Removed account "{acc.username}"' - result = run(cli.lists.list_accounts, "foo") + result = run(cli.lists.accounts, "foo") assert result.exit_code == 0 assert result.stdout.strip() == "This list has no accounts." diff --git a/toot/api.py b/toot/api.py index 1a7d3e5..c6aa91a 100644 --- a/toot/api.py +++ b/toot/api.py @@ -629,14 +629,6 @@ def get_lists(app, user): return http.get(app, user, "/api/v1/lists").json() -def find_list_id(app, user, title): - lists = get_lists(app, user) - for list_item in lists: - if list_item["title"] == title: - return list_item["id"] - return None - - def get_list_accounts(app, user, list_id): path = f"/api/v1/lists/{list_id}/accounts" return _get_response_list(app, user, path) diff --git a/toot/cli/lists.py b/toot/cli/lists.py index 089a2fa..0b485c6 100644 --- a/toot/cli/lists.py +++ b/toot/cli/lists.py @@ -2,14 +2,28 @@ import click from toot import api from toot.cli.base import Context, cli, pass_context -from toot.output import print_list_accounts, print_lists +from toot.output import print_list_accounts, print_lists, print_warning -@cli.command() -@pass_context -def lists(ctx: Context): - """List all lists""" - lists = api.get_lists(ctx.app, ctx.user) +@cli.group(invoke_without_command=True) +@click.pass_context +def lists(ctx: click.Context): + """Display and manage lists""" + if ctx.invoked_subcommand is None: + print_warning("`toot lists` is deprecated in favour of `toot lists list`") + lists = api.get_lists(ctx.obj.app, ctx.obj.user) + + if lists: + print_lists(lists) + else: + click.echo("You have no lists defined.") + + +@lists.command() +@click.pass_context +def list(ctx: click.Context): + """List all your lists""" + lists = api.get_lists(ctx.obj.app, ctx.obj.user) if lists: print_lists(lists) @@ -17,18 +31,99 @@ def lists(ctx: Context): click.echo("You have no lists defined.") -@cli.command(name="list_accounts") +@lists.command() @click.argument("title", required=False) @click.option("--id", help="List ID if not title is given") @pass_context -def list_accounts(ctx: Context, title: str, id: str): +def accounts(ctx: Context, title: str, id: str): """List the accounts in a list""" list_id = _get_list_id(ctx, title, id) response = api.get_list_accounts(ctx.app, ctx.user, list_id) print_list_accounts(response) -@cli.command(name="list_create") +@lists.command() +@click.argument("title") +@click.option( + "--replies-policy", + type=click.Choice(["followed", "list", "none"]), + default="none", + help="Replies policy" +) +@pass_context +def create(ctx: Context, title: str, replies_policy: str): + """Create a list""" + api.create_list(ctx.app, ctx.user, title=title, replies_policy=replies_policy) + click.secho(f"✓ List \"{title}\" created.", fg="green") + + +@lists.command() +@click.argument("title", required=False) +@click.option("--id", help="List ID if not title is given") +@pass_context +def delete(ctx: Context, title: str, id: str): + """Delete a list""" + list_id = _get_list_id(ctx, title, id) + api.delete_list(ctx.app, ctx.user, list_id) + click.secho(f"✓ List \"{title if title else id}\" deleted.", fg="green") + + +@lists.command() +@click.argument("title", required=False) +@click.argument("account") +@click.option("--id", help="List ID if not title is given") +@pass_context +def add(ctx: Context, title: str, account: str, id: str): + """Add an account to a list""" + list_id = _get_list_id(ctx, title, id) + found_account = api.find_account(ctx.app, ctx.user, account) + + try: + api.add_accounts_to_list(ctx.app, ctx.user, list_id, [found_account["id"]]) + except Exception: + # TODO: this is slow, improve + # if we failed to add the account, try to give a + # more specific error message than "record not found" + my_accounts = api.followers(ctx.app, ctx.user, found_account["id"]) + found = False + if my_accounts: + for my_account in my_accounts: + if my_account["id"] == found_account["id"]: + found = True + break + if found is False: + raise click.ClickException(f"You must follow @{account} before adding this account to a list.") + raise + + click.secho(f"✓ Added account \"{account}\"", fg="green") + + +@lists.command() +@click.argument("title", required=False) +@click.argument("account") +@click.option("--id", help="List ID if not title is given") +@pass_context +def remove(ctx: Context, title: str, account: str, id: str): + """Remove an account from a list""" + list_id = _get_list_id(ctx, title, id) + found_account = api.find_account(ctx.app, ctx.user, account) + api.remove_accounts_from_list(ctx.app, ctx.user, list_id, [found_account["id"]]) + click.secho(f"✓ Removed account \"{account}\"", fg="green") + + +@cli.command(name="list_accounts", hidden=True) +@click.argument("title", required=False) +@click.option("--id", help="List ID if not title is given") +@pass_context +def list_accounts(ctx: Context, title: str, id: str): + """List the accounts in a list""" + print_warning("`toot list_accounts` is deprecated in favour of `toot lists accounts`") + list_id = _get_list_id(ctx, title, id) + response = api.get_list_accounts(ctx.app, ctx.user, list_id) + print_list_accounts(response) + + +@cli.command(name="list_create", hidden=True) @click.argument("title") @click.option( "--replies-policy", @@ -39,28 +134,31 @@ def list_accounts(ctx: Context, title: str, id: str): @pass_context def list_create(ctx: Context, title: str, replies_policy: str): """Create a list""" + print_warning("`toot list_create` is deprecated in favour of `toot lists create`") api.create_list(ctx.app, ctx.user, title=title, replies_policy=replies_policy) click.secho(f"✓ List \"{title}\" created.", fg="green") -@cli.command(name="list_delete") +@cli.command(name="list_delete", hidden=True) @click.argument("title", required=False) @click.option("--id", help="List ID if not title is given") @pass_context def list_delete(ctx: Context, title: str, id: str): """Delete a list""" + print_warning("`toot list_delete` is deprecated in favour of `toot lists delete`") list_id = _get_list_id(ctx, title, id) api.delete_list(ctx.app, ctx.user, list_id) click.secho(f"✓ List \"{title if title else id}\" deleted.", fg="green") -@cli.command(name="list_add") +@cli.command(name="list_add", hidden=True) @click.argument("title", required=False) @click.argument("account") @click.option("--id", help="List ID if not title is given") @pass_context def list_add(ctx: Context, title: str, account: str, id: str): """Add an account to a list""" + print_warning("`toot list_add` is deprecated in favour of `toot lists add`") list_id = _get_list_id(ctx, title, id) found_account = api.find_account(ctx.app, ctx.user, account) @@ -83,13 +181,14 @@ def list_add(ctx: Context, title: str, account: str, id: str): click.secho(f"✓ Added account \"{account}\"", fg="green") -@cli.command(name="list_remove") +@cli.command(name="list_remove", hidden=True) @click.argument("title", required=False) @click.argument("account") @click.option("--id", help="List ID if not title is given") @pass_context def list_remove(ctx: Context, title: str, account: str, id: str): """Remove an account from a list""" + print_warning("`toot list_remove` is deprecated in favour of `toot lists remove`") list_id = _get_list_id(ctx, title, id) found_account = api.find_account(ctx.app, ctx.user, account) api.remove_accounts_from_list(ctx.app, ctx.user, list_id, [found_account["id"]]) @@ -97,8 +196,19 @@ def list_remove(ctx: Context, title: str, account: str, id: str): def _get_list_id(ctx: Context, title, list_id): - if not list_id: - list_id = api.find_list_id(ctx.app, ctx.user, title) - if not list_id: + if not list_id and not title: + raise click.ClickException("Please specify list title or ID") + + lists = api.get_lists(ctx.app, ctx.user) + matched_ids = [ + list["id"] for list in lists + if list["title"].lower() == title.lower() or list["id"] == list_id + ] + + if not matched_ids: raise click.ClickException("List not found") - return list_id + + if len(matched_ids) > 1: + raise click.ClickException("Found multiple lists with the same title, please specify the ID instead") + + return matched_ids[0] From 2f3f686a00deb853e5fbd029872f0b10203e4aee Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Thu, 14 Dec 2023 10:11:09 +0100 Subject: [PATCH 57/59] Rework how app, user are passed to context --- tests/integration/conftest.py | 17 +++++++------- toot/cli/base.py | 43 +++++++++++++++++++++++------------ toot/cli/lists.py | 14 ++++++++---- 3 files changed, 46 insertions(+), 28 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index c0b14ee..e878b7b 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -24,6 +24,7 @@ from click.testing import CliRunner, Result from pathlib import Path from toot import api, App, User from toot.cli import Context +from toot.cli.base import TootObj def pytest_configure(config): @@ -116,24 +117,24 @@ def runner(): @pytest.fixture def run(app, user, runner): def _run(command, *params, input=None) -> Result: - ctx = Context(app, user) - return runner.invoke(command, params, obj=ctx, input=input) + obj = TootObj(test_ctx=Context(app, user)) + return runner.invoke(command, params, obj=obj, input=input) return _run @pytest.fixture def run_as(app, runner): def _run_as(user, command, *params, input=None) -> Result: - ctx = Context(app, user) - return runner.invoke(command, params, obj=ctx, input=input) + obj = TootObj(test_ctx=Context(app, user)) + return runner.invoke(command, params, obj=obj, input=input) return _run_as @pytest.fixture def run_json(app, user, runner): def _run_json(command, *params): - ctx = Context(app, user) - result = runner.invoke(command, params, obj=ctx) + obj = TootObj(test_ctx=Context(app, user)) + result = runner.invoke(command, params, obj=obj) assert result.exit_code == 0 return json.loads(result.stdout) return _run_json @@ -142,8 +143,8 @@ def run_json(app, user, runner): @pytest.fixture def run_anon(runner): def _run(command, *params) -> Result: - ctx = Context(None, None) - return runner.invoke(command, params, obj=ctx) + obj = TootObj(test_ctx=Context(None, None)) + return runner.invoke(command, params, obj=obj) return _run diff --git a/toot/cli/base.py b/toot/cli/base.py index 4a7362e..71df90f 100644 --- a/toot/cli/base.py +++ b/toot/cli/base.py @@ -64,7 +64,6 @@ CONTEXT = dict( ) -# Data object to add to Click context class Context(t.NamedTuple): app: t.Optional[App] user: t.Optional[User] = None @@ -73,16 +72,39 @@ class Context(t.NamedTuple): quiet: bool = False +class TootObj(t.NamedTuple): + """Data to add to Click context""" + color: bool = True + debug: bool = False + quiet: bool = False + # Pass a context for testing purposes + test_ctx: t.Optional[Context] = None + + def pass_context(f: "t.Callable[te.Concatenate[Context, P], R]") -> "t.Callable[P, R]": - """Pass `obj` from click context as first argument.""" + """Pass the toot Context as first argument.""" @wraps(f) def wrapped(*args: "P.args", **kwargs: "P.kwargs") -> R: - ctx = click.get_current_context() - return f(ctx.obj, *args, **kwargs) + return f(_get_context(), *args, **kwargs) return wrapped +def _get_context() -> Context: + click_context = click.get_current_context() + obj: TootObj = click_context.obj + + # This is used to pass a context for testing, not used in normal usage + if obj.test_ctx: + return obj.test_ctx + + user, app = config.get_active_user_app() + if not user or not app: + raise click.ClickException("This command requires you to be logged in.") + + return Context(app, user, obj.color, obj.debug, obj.quiet) + + json_option = click.option( "--json", is_flag=True, @@ -98,18 +120,9 @@ json_option = click.option( @click.option("--quiet/--no-quiet", default=False, help="Don't print anything to stdout") @click.version_option(__version__, message="%(prog)s v%(version)s") @click.pass_context -def cli( - ctx: click.Context, - max_width: int, - color: bool, - debug: bool, - quiet: bool, - app: t.Optional[App] = None, - user: t.Optional[User] = None, -): +def cli(ctx: click.Context, max_width: int, color: bool, debug: bool, quiet: bool): """Toot is a Mastodon CLI""" - user, app = config.get_active_user_app() - ctx.obj = Context(app, user, color, debug, quiet) + ctx.obj = TootObj(color, debug, quiet) ctx.color = color ctx.max_content_width = max_width diff --git a/toot/cli/lists.py b/toot/cli/lists.py index 0b485c6..7fe30df 100644 --- a/toot/cli/lists.py +++ b/toot/cli/lists.py @@ -1,6 +1,6 @@ import click -from toot import api +from toot import api, config from toot.cli.base import Context, cli, pass_context from toot.output import print_list_accounts, print_lists, print_warning @@ -11,8 +11,12 @@ def lists(ctx: click.Context): """Display and manage lists""" if ctx.invoked_subcommand is None: print_warning("`toot lists` is deprecated in favour of `toot lists list`") - lists = api.get_lists(ctx.obj.app, ctx.obj.user) + user, app = config.get_active_user_app() + if not user or not app: + raise click.ClickException("This command requires you to be logged in.") + + lists = api.get_lists(app, user) if lists: print_lists(lists) else: @@ -20,10 +24,10 @@ def lists(ctx: click.Context): @lists.command() -@click.pass_context -def list(ctx: click.Context): +@pass_context +def list(ctx: Context): """List all your lists""" - lists = api.get_lists(ctx.obj.app, ctx.obj.user) + lists = api.get_lists(ctx.app, ctx.user) if lists: print_lists(lists) From f72e4ba84427b0375dc86b8ea7808a032d17f7a7 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Thu, 14 Dec 2023 11:35:52 +0100 Subject: [PATCH 58/59] Move code from toot.tui.base to toot.tui --- tests/integration/conftest.py | 3 +- tests/integration/test_auth.py | 2 +- toot/cli/__init__.py | 131 ++++++++++++++++++++++++++++++++- toot/cli/accounts.py | 2 +- toot/cli/auth.py | 2 +- toot/cli/base.py | 130 -------------------------------- toot/cli/lists.py | 2 +- toot/cli/post.py | 4 +- toot/cli/read.py | 2 +- toot/cli/statuses.py | 4 +- toot/cli/tags.py | 2 +- toot/cli/timelines.py | 2 +- toot/cli/tui.py | 2 +- toot/cli/validators.py | 2 +- toot/tui/app.py | 2 +- toot/tui/compose.py | 2 +- 16 files changed, 146 insertions(+), 148 deletions(-) delete mode 100644 toot/cli/base.py diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index e878b7b..fea6477 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -23,8 +23,7 @@ import uuid from click.testing import CliRunner, Result from pathlib import Path from toot import api, App, User -from toot.cli import Context -from toot.cli.base import TootObj +from toot.cli import Context, TootObj def pytest_configure(config): diff --git a/tests/integration/test_auth.py b/tests/integration/test_auth.py index 1cc0672..858d6fb 100644 --- a/tests/integration/test_auth.py +++ b/tests/integration/test_auth.py @@ -3,7 +3,7 @@ from unittest import mock from unittest.mock import MagicMock from toot import User, cli -from toot.cli.base import Run +from toot.cli import Run # TODO: figure out how to test login diff --git a/toot/cli/__init__.py b/toot/cli/__init__.py index 6f81aa0..d75f267 100644 --- a/toot/cli/__init__.py +++ b/toot/cli/__init__.py @@ -1,5 +1,134 @@ # flake8: noqa -from toot.cli.base import cli, Context +import click +import logging +import os +import sys +import typing as t + +from click.testing import Result +from functools import wraps +from toot import App, User, config, __version__ +from toot.settings import get_settings + +if t.TYPE_CHECKING: + import typing_extensions as te + P = te.ParamSpec("P") + +R = t.TypeVar("R") +T = t.TypeVar("T") + + +PRIVACY_CHOICES = ["public", "unlisted", "private"] +VISIBILITY_CHOICES = ["public", "unlisted", "private", "direct"] + +TUI_COLORS = { + "1": 1, + "16": 16, + "88": 88, + "256": 256, + "16777216": 16777216, + "24bit": 16777216, +} +TUI_COLORS_CHOICES = list(TUI_COLORS.keys()) +TUI_COLORS_VALUES = list(TUI_COLORS.values()) + +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 = t.Callable[..., Result] + + +def get_default_visibility() -> str: + return os.getenv("TOOT_POST_VISIBILITY", "public") + + +def get_default_map(): + settings = get_settings() + common = settings.get("common", {}) + commands = settings.get("commands", {}) + return {**common, **commands} + + +# Tweak the Click context +# https://click.palletsprojects.com/en/8.1.x/api/#context +CONTEXT = dict( + # Enable using environment variables to set options + auto_envvar_prefix="TOOT", + # Add shorthand -h for invoking help + help_option_names=["-h", "--help"], + # Always show default values for options + show_default=True, + # Load command defaults from settings + default_map=get_default_map(), +) + + +class Context(t.NamedTuple): + app: t.Optional[App] + user: t.Optional[User] = None + color: bool = False + debug: bool = False + quiet: bool = False + + +class TootObj(t.NamedTuple): + """Data to add to Click context""" + color: bool = True + debug: bool = False + quiet: bool = False + # Pass a context for testing purposes + test_ctx: t.Optional[Context] = None + + +def pass_context(f: "t.Callable[te.Concatenate[Context, P], R]") -> "t.Callable[P, R]": + """Pass the toot Context as first argument.""" + @wraps(f) + def wrapped(*args: "P.args", **kwargs: "P.kwargs") -> R: + return f(_get_context(), *args, **kwargs) + + return wrapped + + +def _get_context() -> Context: + click_context = click.get_current_context() + obj: TootObj = click_context.obj + + # This is used to pass a context for testing, not used in normal usage + if obj.test_ctx: + return obj.test_ctx + + user, app = config.get_active_user_app() + if not user or not app: + raise click.ClickException("This command requires you to be logged in.") + + return Context(app, user, obj.color, obj.debug, obj.quiet) + + +json_option = click.option( + "--json", + is_flag=True, + default=False, + help="Print data as JSON rather than human readable text" +) + + +@click.group(context_settings=CONTEXT) +@click.option("-w", "--max-width", type=int, default=80, help="Maximum width for content rendered by toot") +@click.option("--debug/--no-debug", default=False, help="Log debug info to stderr") +@click.option("--color/--no-color", default=sys.stdout.isatty(), help="Use ANSI color in output") +@click.option("--quiet/--no-quiet", default=False, help="Don't print anything to stdout") +@click.version_option(__version__, message="%(prog)s v%(version)s") +@click.pass_context +def cli(ctx: click.Context, max_width: int, color: bool, debug: bool, quiet: bool): + """Toot is a Mastodon CLI""" + ctx.obj = TootObj(color, debug, quiet) + ctx.color = color + ctx.max_content_width = max_width + + if debug: + logging.basicConfig(level=logging.DEBUG) from toot.cli import accounts from toot.cli import auth diff --git a/toot/cli/accounts.py b/toot/cli/accounts.py index 5cb66b9..01c499d 100644 --- a/toot/cli/accounts.py +++ b/toot/cli/accounts.py @@ -4,7 +4,7 @@ import json as pyjson from typing import BinaryIO, Optional from toot import api -from toot.cli.base import PRIVACY_CHOICES, cli, json_option, Context, pass_context +from toot.cli import PRIVACY_CHOICES, cli, json_option, Context, pass_context from toot.cli.validators import validate_language from toot.output import print_acct_list diff --git a/toot/cli/auth.py b/toot/cli/auth.py index e5a7c8e..c72f0c4 100644 --- a/toot/cli/auth.py +++ b/toot/cli/auth.py @@ -8,7 +8,7 @@ from click.types import StringParamType 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 import cli from toot.cli.validators import validate_instance diff --git a/toot/cli/base.py b/toot/cli/base.py deleted file mode 100644 index 71df90f..0000000 --- a/toot/cli/base.py +++ /dev/null @@ -1,130 +0,0 @@ -import click -import logging -import os -import sys -import typing as t - -from click.testing import Result -from functools import wraps -from toot import App, User, config, __version__ -from toot.settings import get_settings - -if t.TYPE_CHECKING: - import typing_extensions as te - P = te.ParamSpec("P") - -R = t.TypeVar("R") -T = t.TypeVar("T") - - -PRIVACY_CHOICES = ["public", "unlisted", "private"] -VISIBILITY_CHOICES = ["public", "unlisted", "private", "direct"] - -TUI_COLORS = { - "1": 1, - "16": 16, - "88": 88, - "256": 256, - "16777216": 16777216, - "24bit": 16777216, -} -TUI_COLORS_CHOICES = list(TUI_COLORS.keys()) -TUI_COLORS_VALUES = list(TUI_COLORS.values()) - -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 = t.Callable[..., Result] - - -def get_default_visibility() -> str: - return os.getenv("TOOT_POST_VISIBILITY", "public") - - -def get_default_map(): - settings = get_settings() - common = settings.get("common", {}) - commands = settings.get("commands", {}) - return {**common, **commands} - - -# Tweak the Click context -# https://click.palletsprojects.com/en/8.1.x/api/#context -CONTEXT = dict( - # Enable using environment variables to set options - auto_envvar_prefix="TOOT", - # Add shorthand -h for invoking help - help_option_names=["-h", "--help"], - # Always show default values for options - show_default=True, - # Load command defaults from settings - default_map=get_default_map(), -) - - -class Context(t.NamedTuple): - app: t.Optional[App] - user: t.Optional[User] = None - color: bool = False - debug: bool = False - quiet: bool = False - - -class TootObj(t.NamedTuple): - """Data to add to Click context""" - color: bool = True - debug: bool = False - quiet: bool = False - # Pass a context for testing purposes - test_ctx: t.Optional[Context] = None - - -def pass_context(f: "t.Callable[te.Concatenate[Context, P], R]") -> "t.Callable[P, R]": - """Pass the toot Context as first argument.""" - @wraps(f) - def wrapped(*args: "P.args", **kwargs: "P.kwargs") -> R: - return f(_get_context(), *args, **kwargs) - - return wrapped - - -def _get_context() -> Context: - click_context = click.get_current_context() - obj: TootObj = click_context.obj - - # This is used to pass a context for testing, not used in normal usage - if obj.test_ctx: - return obj.test_ctx - - user, app = config.get_active_user_app() - if not user or not app: - raise click.ClickException("This command requires you to be logged in.") - - return Context(app, user, obj.color, obj.debug, obj.quiet) - - -json_option = click.option( - "--json", - is_flag=True, - default=False, - help="Print data as JSON rather than human readable text" -) - - -@click.group(context_settings=CONTEXT) -@click.option("-w", "--max-width", type=int, default=80, help="Maximum width for content rendered by toot") -@click.option("--debug/--no-debug", default=False, help="Log debug info to stderr") -@click.option("--color/--no-color", default=sys.stdout.isatty(), help="Use ANSI color in output") -@click.option("--quiet/--no-quiet", default=False, help="Don't print anything to stdout") -@click.version_option(__version__, message="%(prog)s v%(version)s") -@click.pass_context -def cli(ctx: click.Context, max_width: int, color: bool, debug: bool, quiet: bool): - """Toot is a Mastodon CLI""" - ctx.obj = TootObj(color, debug, quiet) - ctx.color = color - ctx.max_content_width = max_width - - if debug: - logging.basicConfig(level=logging.DEBUG) diff --git a/toot/cli/lists.py b/toot/cli/lists.py index 7fe30df..a7a8fcc 100644 --- a/toot/cli/lists.py +++ b/toot/cli/lists.py @@ -1,7 +1,7 @@ import click from toot import api, config -from toot.cli.base import Context, cli, pass_context +from toot.cli import Context, cli, pass_context from toot.output import print_list_accounts, print_lists, print_warning diff --git a/toot/cli/post.py b/toot/cli/post.py index fa5b578..af0fa60 100644 --- a/toot/cli/post.py +++ b/toot/cli/post.py @@ -7,8 +7,8 @@ from time import sleep, time from typing import BinaryIO, 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 +from toot.cli import cli, json_option, pass_context, Context +from toot.cli import DURATION_EXAMPLES, VISIBILITY_CHOICES from toot.cli.validators import validate_duration, validate_language from toot.entities import MediaAttachment, from_dict from toot.utils import EOF_KEY, delete_tmp_status_file, editor_input, multiline_input diff --git a/toot/cli/read.py b/toot/cli/read.py index 4d5e2d0..341fb8e 100644 --- a/toot/cli/read.py +++ b/toot/cli/read.py @@ -9,7 +9,7 @@ from toot.cli.validators import validate_instance from toot.entities import Instance, Status, from_dict, Account from toot.exceptions import ApiError, ConsoleError from toot.output import print_account, print_instance, print_search_results, print_status, print_timeline -from toot.cli.base import cli, json_option, pass_context, Context +from toot.cli import cli, json_option, pass_context, Context @cli.command() diff --git a/toot/cli/statuses.py b/toot/cli/statuses.py index 9e3ecee..0d088b2 100644 --- a/toot/cli/statuses.py +++ b/toot/cli/statuses.py @@ -1,8 +1,8 @@ import click from toot import api -from toot.cli.base import cli, json_option, Context, pass_context -from toot.cli.base import VISIBILITY_CHOICES +from toot.cli import cli, json_option, Context, pass_context +from toot.cli import VISIBILITY_CHOICES from toot.output import print_table diff --git a/toot/cli/tags.py b/toot/cli/tags.py index 2e8d40a..b79fdf0 100644 --- a/toot/cli/tags.py +++ b/toot/cli/tags.py @@ -2,7 +2,7 @@ import click import json as pyjson from toot import api -from toot.cli.base import cli, pass_context, json_option, Context +from toot.cli import cli, pass_context, json_option, Context from toot.entities import Tag, from_dict from toot.output import print_tag_list, print_warning diff --git a/toot/cli/timelines.py b/toot/cli/timelines.py index ffb382d..fc2be24 100644 --- a/toot/cli/timelines.py +++ b/toot/cli/timelines.py @@ -2,7 +2,7 @@ import sys import click from toot import api -from toot.cli.base import cli, pass_context, Context +from toot.cli import cli, pass_context, Context from typing import Optional from toot.cli.validators import validate_instance diff --git a/toot/cli/tui.py b/toot/cli/tui.py index a0c0f0f..1cedd75 100644 --- a/toot/cli/tui.py +++ b/toot/cli/tui.py @@ -1,7 +1,7 @@ import click from typing import Optional -from toot.cli.base import TUI_COLORS, Context, cli, pass_context +from toot.cli import TUI_COLORS, Context, cli, pass_context from toot.cli.validators import validate_tui_colors from toot.tui.app import TUI, TuiOptions diff --git a/toot/cli/validators.py b/toot/cli/validators.py index 819fdf9..6b7c8fe 100644 --- a/toot/cli/validators.py +++ b/toot/cli/validators.py @@ -4,7 +4,7 @@ import re from click import Context from typing import Optional -from toot.cli.base import TUI_COLORS +from toot.cli import TUI_COLORS def validate_language(ctx: Context, param: str, value: Optional[str]): diff --git a/toot/tui/app.py b/toot/tui/app.py index bc712cd..82747a2 100644 --- a/toot/tui/app.py +++ b/toot/tui/app.py @@ -7,7 +7,7 @@ from typing import NamedTuple, Optional from toot import api, config, __version__, settings from toot import App, User -from toot.cli.base import get_default_visibility +from toot.cli import get_default_visibility from toot.exceptions import ApiError from .compose import StatusComposer diff --git a/toot/tui/compose.py b/toot/tui/compose.py index 4d4b77a..c4a038a 100644 --- a/toot/tui/compose.py +++ b/toot/tui/compose.py @@ -1,7 +1,7 @@ import urwid import logging -from toot.cli.base import get_default_visibility +from toot.cli import get_default_visibility from .constants import VISIBILITY_OPTIONS from .widgets import Button, EditBox From 44ea2e8e6f705a56d6540ab540738a15d2fe7362 Mon Sep 17 00:00:00 2001 From: Ivan Habunek Date: Thu, 14 Dec 2023 11:57:33 +0100 Subject: [PATCH 59/59] Don't ignore the whole file by flake8 --- toot/cli/__init__.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/toot/cli/__init__.py b/toot/cli/__init__.py index d75f267..0e345ac 100644 --- a/toot/cli/__init__.py +++ b/toot/cli/__init__.py @@ -1,4 +1,3 @@ -# flake8: noqa import click import logging import os @@ -130,12 +129,13 @@ def cli(ctx: click.Context, max_width: int, color: bool, debug: bool, quiet: boo if debug: logging.basicConfig(level=logging.DEBUG) -from toot.cli import accounts -from toot.cli import auth -from toot.cli import lists -from toot.cli import post -from toot.cli import read -from toot.cli import statuses -from toot.cli import tags -from toot.cli import timelines -from toot.cli import tui + +from toot.cli import accounts # noqa +from toot.cli import auth # noqa +from toot.cli import lists # noqa +from toot.cli import post # noqa +from toot.cli import read # noqa +from toot.cli import statuses # noqa +from toot.cli import tags # noqa +from toot.cli import timelines # noqa +from toot.cli import tui # noqa