Toot-Mastodon-CLI-TUI-clien.../toot/console.py

699 lines
19 KiB
Python
Raw Normal View History

import logging
2018-01-14 15:34:41 +01:00
import os
2022-12-01 10:20:50 +01:00
import re
import shutil
2017-04-12 16:42:04 +02:00
import sys
from argparse import ArgumentParser, FileType, ArgumentTypeError
2017-04-19 14:47:30 +02:00
from collections import namedtuple
2022-12-31 09:11:05 +01:00
from itertools import chain
2018-06-12 12:22:16 +02:00
from toot import config, commands, CLIENT_NAME, CLIENT_WEBSITE, __version__
2017-12-30 13:32:52 +01:00
from toot.exceptions import ApiError, ConsoleError
from toot.output import print_out, print_err
2017-04-19 14:47:30 +02:00
VISIBILITY_CHOICES = ['public', 'unlisted', 'private', 'direct']
2022-12-28 09:48:44 +01:00
VISIBILITY_CHOICES_STR = ", ".join(f"'{v}'" for v in VISIBILITY_CHOICES)
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
2017-04-19 14:47:30 +02:00
def visibility(value):
"""Validates the visibility parameter"""
2017-04-19 14:47:30 +02:00
if value not in VISIBILITY_CHOICES:
raise ValueError("Invalid visibility value")
2017-04-19 14:47:30 +02:00
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
2022-12-01 10:20:50 +01:00
DURATION_UNITS = {
"m": 60,
"h": 60 * 60,
"d": 60 * 60 * 24,
}
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:
2019-08-29 12:46:00 +02:00
raise ArgumentTypeError("Editor `{}` not found".format(value))
return exe
2017-04-19 14:47:30 +02:00
Command = namedtuple("Command", ["name", "description", "require_auth", "arguments"])
2020-01-02 10:34:00 +01:00
# 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",
}),
]
2017-04-26 11:49:21 +02:00
account_arg = (["account"], {
"help": "account name, e.g. 'Gargron@mastodon.social'",
2017-04-26 11:49:21 +02:00
})
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',
})
2018-12-25 02:20:30 +01:00
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,
})
2017-04-26 11:49:21 +02:00
2022-12-28 09:48:44 +01:00
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\"",
})
2022-12-28 09:48:44 +01:00
# 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)",
}),
(["-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 <count> toots, do not prompt to continue.",
}),
]
timeline_args = common_timeline_args + timeline_and_bookmark_args
AUTH_COMMANDS = [
2017-04-19 14:47:30 +02:00
Command(
name="login",
2018-06-15 09:39:28 +02:00
description="Log into a mastodon instance using your browser (recommended)",
2018-12-25 02:20:30 +01:00
arguments=[instance_arg, scheme_arg],
require_auth=False,
),
Command(
2018-06-15 09:39:28 +02:00
name="login_cli",
description="Log in from the console, does NOT support two factor authentication",
2018-12-25 02:20:30 +01:00
arguments=[instance_arg, email_arg, scheme_arg],
require_auth=False,
),
Command(
name="activate",
description="Switch between logged in accounts.",
arguments=[account_arg],
2017-04-19 14:47:30 +02:00
require_auth=False,
),
Command(
name="logout",
description="Log out, delete stored access keys",
arguments=[account_arg],
2017-04-19 14:47:30 +02:00
require_auth=False,
),
Command(
name="auth",
description="Show logged in accounts and instances",
2017-04-19 14:47:30 +02:00
arguments=[],
require_auth=False,
),
2022-12-11 23:46:54 +01:00
Command(
name="env",
description="Print environment information for inclusion in bug reports.",
arguments=[],
require_auth=False,
),
]
2019-08-29 13:44:06 +02:00
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.",
}),
],
2020-01-02 10:21:58 +01:00
require_auth=True,
2019-08-29 13:44:06 +02:00
),
]
READ_COMMANDS = [
2017-04-19 14:47:30 +02:00
Command(
name="whoami",
description="Display logged in user details",
arguments=[],
require_auth=True,
),
2017-04-19 15:29:40 +02:00
Command(
name="whois",
description="Display account details",
2017-04-19 15:29:40 +02:00
arguments=[
(["account"], {
"help": "account name or numeric ID"
}),
],
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,
),
2017-12-29 14:26:40 +01:00
Command(
name="instance",
description="Display instance details",
arguments=[
(["instance"], {
"help": "instance domain (e.g. 'mastodon.social') or blank to use current",
"nargs": "?",
}),
scheme_arg,
2017-12-29 14:26:40 +01:00
],
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",
}),
],
require_auth=True,
),
Command(
name="thread",
description="Show toot thread items",
arguments=[
(["status_id"], {
"help": "Show thread for toot.",
}),
],
require_auth=True,
),
Command(
name="timeline",
2018-06-12 10:40:36 +02:00
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 = [
2017-04-19 14:47:30 +02:00
Command(
name="post",
description="Post a status text to your timeline",
arguments=[
(["text"], {
"help": "The status text to post.",
"nargs": "?",
2017-04-19 14:47:30 +02:00
}),
(["-m", "--media"], {
"action": "append",
"type": FileType("rb"),
"help": "path to the media file to attach (specify multiple "
"times to attach up to 4 files)"
2017-04-19 14:47:30 +02:00
}),
2021-08-26 17:54:31 +02:00
(["-d", "--description"], {
"action": "append",
"type": str,
"help": "plain-text description of the media for accessibility "
"purposes, one per attached media"
}),
2022-12-28 09:48:44 +01:00
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",
2018-06-13 12:43:31 +02:00
}),
(["-r", "--reply-to"], {
"type": str,
"help": "local ID of the status you want to reply to",
}),
(["-l", "--language"], {
"type": language,
"help": "ISO 639-2 language code of the toot, to skip automatic detection",
2018-06-13 12:43:31 +02:00
}),
(["-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.",
}),
2021-01-17 12:42:08 +01:00
(["--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": """Schedule the toot to be posted after a given amount
of time. Examples: "1 day", "2 hours 30 minutes",
"5 minutes 30 seconds" or any combination of above.
Shorthand: "1d", "2h30m", "5m30s". Must be at least 5
minutes.""",
}),
2021-08-28 21:08:44 +02:00
(["-t", "--content-type"], {
"type": str,
"help": "MIME type for the status text (not supported on all instances)",
}),
2017-04-19 14:47:30 +02:00
],
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')
2021-08-26 17:54:31 +02:00
}),
(["-d", "--description"], {
"type": str,
"help": "plain-text description of the media for accessibility purposes"
}),
2017-04-19 14:47:30 +02:00
],
require_auth=True,
),
]
STATUS_COMMANDS = [
2018-06-14 10:40:16 +02:00
Command(
name="delete",
description="Delete a status",
arguments=[status_id_arg],
require_auth=True,
),
Command(
name="favourite",
description="Favourite a status",
arguments=[status_id_arg],
require_auth=True,
),
Command(
name="unfavourite",
description="Unfavourite a status",
arguments=[status_id_arg],
require_auth=True,
),
Command(
name="reblog",
description="Reblog a status",
2022-12-28 09:48:44 +01:00
arguments=[status_id_arg, visibility_arg],
require_auth=True,
),
Command(
name="unreblog",
description="Unreblog a status",
arguments=[status_id_arg],
require_auth=True,
),
2019-01-24 09:36:25 +01:00
Command(
name="reblogged_by",
description="Show accounts that reblogged the status",
arguments=[status_id_arg],
require_auth=False,
),
Command(
name="pin",
description="Pin a status",
arguments=[status_id_arg],
require_auth=True,
),
Command(
name="unpin",
description="Unpin a status",
arguments=[status_id_arg],
2018-06-14 10:40:16 +02:00
require_auth=True,
),
2022-11-17 06:28:41 +01:00
Command(
name="bookmark",
description="Bookmark a status",
arguments=[status_id_arg],
require_auth=True,
),
Command(
name="unbookmark",
description="Unbookmark a status",
arguments=[status_id_arg],
require_auth=True,
),
]
ACCOUNTS_COMMANDS = [
2017-04-19 14:47:30 +02:00
Command(
name="follow",
description="Follow an account",
arguments=[
2017-04-26 11:49:21 +02:00
account_arg,
2017-04-19 14:47:30 +02:00
],
require_auth=True,
),
Command(
name="unfollow",
description="Unfollow an account",
arguments=[
2017-04-26 11:49:21 +02:00
account_arg,
],
require_auth=True,
),
Command(
name="following",
2022-11-23 15:07:12 +01:00
description="List accounts followed by the given account",
arguments=[
account_arg,
],
require_auth=True,
),
Command(
name="followers",
2022-11-23 15:07:12 +01:00
description="List accounts following the given account",
arguments=[
account_arg,
],
require_auth=True,
),
2017-04-26 11:49:21 +02:00
Command(
name="mute",
description="Mute an account",
arguments=[
account_arg,
],
require_auth=True,
),
Command(
name="unmute",
description="Unmute an account",
arguments=[
account_arg,
],
require_auth=True,
),
Command(
name="block",
description="Block an account",
arguments=[
account_arg,
],
require_auth=True,
),
Command(
name="unblock",
description="Unblock an account",
arguments=[
account_arg,
2017-04-19 14:47:30 +02:00
],
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,
),
]
2022-12-31 09:11:05 +01:00
COMMAND_GROUPS = [
("Authentication", AUTH_COMMANDS),
("TUI", TUI_COMMANDS),
("Read", READ_COMMANDS),
("Post", POST_COMMANDS),
("Status", STATUS_COMMANDS),
("Accounts", ACCOUNTS_COMMANDS),
("Hashtags", TAG_COMMANDS),
]
COMMANDS = list(chain(*[commands for _, commands in COMMAND_GROUPS]))
2017-04-12 16:42:04 +02:00
def print_usage():
2022-12-31 09:11:05 +01:00
max_name_len = max(len(name) for name, _ in COMMAND_GROUPS)
print_out("<green>{}</green>".format(CLIENT_NAME))
2018-06-12 12:22:16 +02:00
print_out("<blue>v{}</blue>".format(__version__))
2017-04-19 14:47:30 +02:00
2022-12-31 09:11:05 +01:00
for name, cmds in COMMAND_GROUPS:
print_out("")
print_out(name + ":")
2017-04-19 14:47:30 +02:00
for cmd in cmds:
cmd_name = cmd.name.ljust(max_name_len + 2)
print_out(" <yellow>toot {}</yellow> {}".format(cmd_name, cmd.description))
2017-04-19 14:47:30 +02:00
print_out("")
print_out("To get help for each command run:")
print_out(" <yellow>toot \\<command> --help</yellow>")
print_out("")
print_out("<green>{}</green>".format(CLIENT_WEBSITE))
2017-04-12 16:42:04 +02:00
2017-04-19 14:47:30 +02:00
def get_argument_parser(name, command):
parser = ArgumentParser(
prog='toot %s' % name,
description=command.description,
epilog=CLIENT_WEBSITE)
2017-04-12 16:42:04 +02:00
combined_args = command.arguments + common_args
if command.require_auth:
combined_args += common_auth_args
for args, kwargs in combined_args:
parser.add_argument(*args, **kwargs)
2017-04-19 14:47:30 +02:00
return parser
2017-04-12 16:42:04 +02:00
2017-04-15 12:12:33 +02:00
2017-04-19 14:47:30 +02:00
def run_command(app, user, name, args):
command = next((c for c in COMMANDS if c.name == name), None)
2017-04-12 16:42:04 +02:00
2017-04-19 14:47:30 +02:00
if not command:
print_err(f"Unknown command '{name}'")
print_out("Run <yellow>toot --help</yellow> to show a list of available commands.")
2017-04-16 17:15:05 +02:00
return
2017-04-19 14:47:30 +02:00
parser = get_argument_parser(name, command)
parsed_args = parser.parse_args(args)
2017-04-13 13:52:28 +02:00
# 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))
2017-04-19 14:47:30 +02:00
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.")
2017-04-13 13:52:28 +02:00
return
2017-04-19 14:47:30 +02:00
fn = commands.__dict__.get(name)
2017-04-13 13:52:28 +02:00
2017-04-19 15:29:40 +02:00
if not fn:
raise NotImplementedError("Command '{}' does not have an implementation.".format(name))
2017-04-19 14:47:30 +02:00
return fn(app, user, parsed_args)
2017-04-13 13:52:28 +02:00
def main():
2018-01-14 15:34:41 +01:00
# Enable debug logging if --debug is in args
if "--debug" in sys.argv:
2018-01-14 15:34:41 +01:00
filename = os.getenv("TOOT_LOG_FILE")
logging.basicConfig(level=logging.DEBUG, filename=filename)
2017-04-12 16:42:04 +02:00
2017-04-19 14:47:30 +02:00
command_name = sys.argv[1] if len(sys.argv) > 1 else None
args = sys.argv[2:]
2017-04-12 16:42:04 +02:00
if not command_name or command_name == "--help":
2017-04-13 13:52:28 +02:00
return print_usage()
user, app = config.get_active_user_app()
2017-04-19 14:47:30 +02:00
2017-04-13 13:52:28 +02:00
try:
2017-04-19 14:47:30 +02:00
run_command(app, user, command_name, args)
except (ConsoleError, ApiError) as e:
print_err(str(e))
sys.exit(1)
except KeyboardInterrupt:
pass