Merge branch 'master' into images

This commit is contained in:
Daniel Schwarz 2024-01-06 13:04:59 -05:00
commit b94c500c9c
27 changed files with 492 additions and 139 deletions

View File

@ -3,20 +3,42 @@ Changelog
<!-- Do not edit. This file is automatically generated from changelog.yaml.-->
**0.40.0 (TBA)**
**0.41.1 (2024-01-02)**
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.
* Fix a crash in settings parsing code
**0.41.0 (2024-01-02)**
* Honour user's default visibility set in Mastodon preferences instead of always
defaulting to public visibility (thanks Lexi Winter)
* TUI: Add editing toots (thanks Lexi Winter)
* TUI: Fix a bug which made pallette config in settings not work
* TUI: Show edit datetime in status detail (thanks Lexi Winter)
**0.40.2 (2023-12-28)**
* Reinstate `toot post --using` option.
* Add shell completion for instances.
**0.40.1 (2023-12-28)**
* Add `toot --as` option to replace `toot post --using`. This now works for all
commands.
**0.40.0 (2023-12-27)**
This release includes a rather extensive change to use the Click library
(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. 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:
* BREAKING: Options `--debug` and `--color` must be specified after `toot` but
before the command
* BREAKING: Option `--quiet` has been removed. Redirect output instead.
* Add passing parameters via environment variables, see:
https://toot.bezdomni.net/environment_variables.html
* Add shell completion, see: https://toot.bezdomni.net/shell_completion.html
* Add `tags info`, `tags featured`, `tags feature`, and `tags unfeature`
@ -26,11 +48,11 @@ noted below please report any issues.
* 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 `--json` option to tags commands
* Add `--json` option to lists commands
* Add `--json` option to tags and lists 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.
* TUI: Fix issue where UI did not render until first input (thanks Urwid devs)
**0.39.0 (2023-11-23)**

View File

@ -1,23 +1,49 @@
0.41.1:
date: 2024-01-02
changes:
- "Fix a crash in settings parsing code"
0.41.0:
date: 2024-01-02
changes:
- "Honour user's default visibility set in Mastodon preferences instead of always defaulting to public visibility (thanks Lexi Winter)"
- "TUI: Add editing toots (thanks Lexi Winter)"
- "TUI: Fix a bug which made pallette config in settings not work"
- "TUI: Show edit datetime in status detail (thanks Lexi Winter)"
0.40.2:
date: 2023-12-28
changes:
- "Reinstate `toot post --using` option."
- "Add shell completion for instances."
0.40.1:
date: 2023-12-28
changes:
- "Add `toot --as` option to replace `toot post --using`. This now works for all commands."
0.40.0:
date: TBA
date: 2023-12-27
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.
This release includes a rather extensive change to use the Click library
(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. 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"
- "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"
- "BREAKING: Options `--debug` and `--color` must be specified after `toot` but before the command"
- "BREAKING: Option `--quiet` has been removed. Redirect output instead."
- "Add passing parameters via environment variables, see: https://toot.bezdomni.net/environment_variables.html"
- "Add shell completion, see: https://toot.bezdomni.net/shell_completion.html"
- "Add `tags info`, `tags featured`, `tags feature`, and `tags unfeature` commands"
- "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 `--json` option to tags commands"
- "Add `--json` option to lists commands"
- "Add `--json` option to tags and lists 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."
- "TUI: Fix issue where UI did not render until first input (thanks Urwid devs)"
0.39.0:
date: 2023-11-23

View File

@ -3,20 +3,42 @@ Changelog
<!-- Do not edit. This file is automatically generated from changelog.yaml.-->
**0.40.0 (TBA)**
**0.41.1 (2024-01-02)**
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.
* Fix a crash in settings parsing code
**0.41.0 (2024-01-02)**
* Honour user's default visibility set in Mastodon preferences instead of always
defaulting to public visibility (thanks Lexi Winter)
* TUI: Add editing toots (thanks Lexi Winter)
* TUI: Fix a bug which made pallette config in settings not work
* TUI: Show edit datetime in status detail (thanks Lexi Winter)
**0.40.2 (2023-12-28)**
* Reinstate `toot post --using` option.
* Add shell completion for instances.
**0.40.1 (2023-12-28)**
* Add `toot --as` option to replace `toot post --using`. This now works for all
commands.
**0.40.0 (2023-12-27)**
This release includes a rather extensive change to use the Click library
(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. 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:
* BREAKING: Options `--debug` and `--color` must be specified after `toot` but
before the command
* BREAKING: Option `--quiet` has been removed. Redirect output instead.
* Add passing parameters via environment variables, see:
https://toot.bezdomni.net/environment_variables.html
* Add shell completion, see: https://toot.bezdomni.net/shell_completion.html
* Add `tags info`, `tags featured`, `tags feature`, and `tags unfeature`
@ -26,11 +48,11 @@ noted below please report any issues.
* 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 `--json` option to tags commands
* Add `--json` option to lists commands
* Add `--json` option to tags and lists 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.
* TUI: Fix issue where UI did not render until first input (thanks Urwid devs)
**0.39.0 (2023-11-23)**

View File

@ -43,6 +43,7 @@ if dist_version != version:
sys.exit(1)
release_date = changelog_item["date"]
description = changelog_item.get("description")
changes = changelog_item["changes"]
if not isinstance(release_date, date):
@ -50,6 +51,11 @@ if not isinstance(release_date, date):
sys.exit(1)
commit_message = f"toot {version}\n\n"
if description:
lines = textwrap.wrap(description.strip(), 72)
commit_message += "\n".join(lines) + "\n\n"
for c in changes:
lines = textwrap.wrap(c, 70)
initial = True

View File

@ -12,7 +12,7 @@ and blocking accounts and other actions.
setup(
name='toot',
version='0.40.0',
version='0.41.1',
description='Mastodon CLI client',
long_description=long_description.strip(),
author='Ivan Habunek',

View File

@ -9,15 +9,14 @@ your test server and database:
```
export TOOT_TEST_BASE_URL="localhost:3000"
export TOOT_TEST_DATABASE_DSN="dbname=mastodon_development"
```
"""
import json
import re
import os
import psycopg2
import pytest
import re
import typing as t
import uuid
from click.testing import CliRunner, Result
@ -31,8 +30,10 @@ def pytest_configure(config):
toot.settings.DISABLE_SETTINGS = True
# Type alias for run commands
Run = t.Callable[..., Result]
# 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
@ -52,17 +53,9 @@ def register_account(app: App):
email = f"{username}@example.com"
response = api.register_account(app, username, email, "password", "en")
confirm_user(email)
return User(app.instance, username, response["access_token"])
def confirm_user(email):
conn = psycopg2.connect(DATABASE_DSN)
cursor = conn.cursor()
cursor.execute("UPDATE users SET confirmed_at = now() WHERE email = %s;", (email,))
conn.commit()
# ------------------------------------------------------------------------------
# Fixtures
# ------------------------------------------------------------------------------

View File

@ -3,7 +3,7 @@ from unittest import mock
from unittest.mock import MagicMock
from toot import User, cli
from toot.cli import Run
from tests.integration.conftest import Run
# TODO: figure out how to test login

View File

@ -1,7 +1,7 @@
import json
import time
import pytest
from tests.utils import run_with_retries
from toot import api, cli
from toot.exceptions import NotFoundError
@ -46,11 +46,11 @@ def test_favourite(app, user, run):
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.2)
status = api.fetch_status(app, user, status["id"]).json()
assert not status["favourited"]
def test_favourited():
nonlocal status
status = api.fetch_status(app, user, status["id"]).json()
assert not status["favourited"]
run_with_retries(test_favourited)
def test_favourite_json(app, user, run):

View File

@ -1,7 +1,7 @@
import pytest
from time import sleep
from uuid import uuid4
from tests.utils import run_with_retries
from toot import api, cli
from toot.entities import from_dict, Status
@ -40,16 +40,14 @@ def test_timelines(app, user, other_user, friend_user, friend_list, run):
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.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
def test_home():
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
run_with_retries(test_home)
# Public timeline
result = run(cli.timelines.timeline, "--public")
@ -166,13 +164,14 @@ def test_notifications(app, user, other_user, run):
text = f"Paging doctor @{user.username}"
status = _post_status(app, other_user, text)
sleep(0.5) # grr
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
def test_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
run_with_retries(test_notifications)
result = run(cli.timelines.notifications, "--mentions")
assert result.exit_code == 0
@ -186,7 +185,6 @@ def test_notifications_follow(app, user, friend_user, run_as):
assert result.exit_code == 0
assert f"@{user.username} now follows you" in result.stdout
result = run_as(friend_user, cli.timelines.notifications, "--mentions")
assert result.exit_code == 0
assert "now follows you" not in result.stdout

View File

@ -2,6 +2,9 @@
Helpers for testing.
"""
import time
from typing import Any, Callable
class MockResponse:
def __init__(self, response_data={}, ok=True, is_redirect=False):
@ -19,3 +22,23 @@ class MockResponse:
def retval(val):
return lambda *args, **kwargs: val
def run_with_retries(fn: Callable[..., Any]):
"""
Run the the given function repeatedly until it finishes without raising an
AssertionError. Sleep a bit between attempts. If the function doesn't
succeed in the given number of tries raises the AssertionError. Used for
tests which should eventually succeed.
"""
# Wait upto 6 seconds with incrementally longer sleeps
delays = [0.1, 0.2, 0.3, 0.4, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5]
for delay in delays:
try:
return fn()
except AssertionError:
time.sleep(delay)
fn()

View File

@ -4,7 +4,7 @@ import sys
from os.path import join, expanduser
from typing import NamedTuple
__version__ = '0.40.0'
__version__ = '0.41.1'
class App(NamedTuple):

View File

@ -183,7 +183,7 @@ def post_status(
app,
user,
status,
visibility='public',
visibility=None,
media_ids=None,
sensitive=False,
spoiler_text=None,
@ -230,6 +230,52 @@ def post_status(
return http.post(app, user, '/api/v1/statuses', json=data, headers=headers)
def edit_status(
app,
user,
id,
status,
visibility='public',
media_ids=None,
sensitive=False,
spoiler_text=None,
in_reply_to_id=None,
language=None,
content_type=None,
poll_options=None,
poll_expires_in=None,
poll_multiple=None,
poll_hide_totals=None,
) -> Response:
"""
Edit an existing status
https://docs.joinmastodon.org/methods/statuses/#edit
"""
# Strip keys for which value is None
# Sending null values doesn't bother Mastodon, but it breaks Pleroma
data = drop_empty_values({
'status': status,
'media_ids': media_ids,
'visibility': visibility,
'sensitive': sensitive,
'in_reply_to_id': in_reply_to_id,
'language': language,
'content_type': content_type,
'spoiler_text': spoiler_text,
})
if poll_options:
data["poll"] = {
"options": poll_options,
"expires_in": poll_expires_in,
"multiple": poll_multiple,
"hide_totals": poll_hide_totals,
}
return http.put(app, user, f"/api/v1/statuses/{id}", json=data)
def fetch_status(app, user, id):
"""
Fetch a single status
@ -238,6 +284,15 @@ def fetch_status(app, user, id):
return http.get(app, user, f"/api/v1/statuses/{id}")
def fetch_status_source(app, user, id):
"""
Fetch the source (original text) for a single status.
This only works on local toots.
https://docs.joinmastodon.org/methods/statuses/#source
"""
return http.get(app, user, f"/api/v1/statuses/{id}/source")
def scheduled_statuses(app, user):
"""
List scheduled statuses
@ -618,6 +673,10 @@ def get_instance(base_url: str) -> Response:
return http.anon_get(url)
def get_preferences(app, user) -> Response:
return http.get(app, user, '/api/v1/preferences')
def get_lists(app, user):
return http.get(app, user, "/api/v1/lists").json()

View File

@ -4,9 +4,12 @@ import os
import sys
import typing as t
from click.testing import Result
from click.shell_completion import CompletionItem
from click.types import StringParamType
from functools import wraps
from toot import App, User, config, __version__
from toot.output import print_warning
from toot.settings import get_settings
if t.TYPE_CHECKING:
@ -35,10 +38,6 @@ 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")
@ -47,6 +46,17 @@ def get_default_map():
settings = get_settings()
common = settings.get("common", {})
commands = settings.get("commands", {})
# TODO: remove in version 1.0
tui_old = settings.get("tui", {}).copy()
if "palette" in tui_old:
del tui_old["palette"]
if tui_old:
# TODO: don't show the warning for [toot.palette]
print_warning("Settings section [tui] has been deprecated in favour of [commands.tui].")
tui_new = commands.get("tui", {})
commands["tui"] = {**tui_old, **tui_new}
return {**common, **commands}
@ -69,18 +79,44 @@ class Context(t.NamedTuple):
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
as_user: t.Optional[str] = None
# Pass a context for testing purposes
test_ctx: t.Optional[Context] = None
class AccountParamType(StringParamType):
"""Custom type to add shell completion for account names"""
name = "account"
def shell_complete(self, ctx, param, incomplete: str):
users = config.load_config()["users"].keys()
return [
CompletionItem(u)
for u in users
if u.lower().startswith(incomplete.lower())
]
class InstanceParamType(StringParamType):
"""Custom type to add shell completion for instance domains"""
name = "instance"
def shell_complete(self, ctx, param, incomplete: str):
apps = config.load_config()["apps"]
return [
CompletionItem(i)
for i in apps.keys()
if i.lower().startswith(incomplete.lower())
]
def pass_context(f: "t.Callable[te.Concatenate[Context, P], R]") -> "t.Callable[P, R]":
"""Pass the toot Context as first argument."""
@wraps(f)
@ -98,11 +134,16 @@ def get_context() -> Context:
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.")
if obj.as_user:
user, app = config.get_user_app(obj.as_user)
if not user or not app:
raise click.ClickException(f"Account '{obj.as_user}' not found. Run `toot auth` to see available accounts.")
else:
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)
return Context(app, user, obj.color, obj.debug)
json_option = click.option(
@ -117,12 +158,12 @@ json_option = click.option(
@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.option("--as", "as_user", type=AccountParamType(), help="The account to use, overrides the active account.")
@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):
def cli(ctx: click.Context, max_width: int, color: bool, debug: bool, as_user: str):
"""Toot is a Mastodon CLI"""
ctx.obj = TootObj(color, debug, quiet)
ctx.obj = TootObj(color, debug, as_user)
ctx.color = color
ctx.max_content_width = max_width

View File

@ -2,13 +2,10 @@ 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
from toot.cli import cli
from toot.cli import AccountParamType, cli
from toot.cli.validators import validate_instance
@ -22,18 +19,6 @@ 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"""

View File

@ -11,7 +11,8 @@ from toot.output import print_list_accounts, print_lists, print_warning
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`")
print_warning("`toot lists` is deprecated in favour of `toot lists list`.\n" +
"Run `toot lists -h` to see other list-related commands.")
user, app = config.get_active_user_app()
if not user or not app:

View File

@ -6,8 +6,8 @@ from datetime import datetime, timedelta, timezone
from time import sleep, time
from typing import BinaryIO, Optional, Tuple
from toot import api
from toot.cli import cli, json_option, pass_context, Context
from toot import api, config
from toot.cli import AccountParamType, 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
@ -40,7 +40,6 @@ from toot.utils.datetime import parse_datetime
"--visibility", "-v",
help="Post visibility",
type=click.Choice(VISIBILITY_CHOICES),
default="public",
)
@click.option(
"--sensitive", "-s",
@ -106,6 +105,11 @@ from toot.utils.datetime import parse_datetime
is_flag=True,
default=False,
)
@click.option(
"-u", "--using",
type=AccountParamType(),
help="The account to use, overrides the active account.",
)
@json_option
@pass_context
def post(
@ -114,7 +118,7 @@ def post(
media: Tuple[str],
descriptions: Tuple[str],
thumbnails: Tuple[str],
visibility: str,
visibility: Optional[str],
sensitive: bool,
spoiler_text: Optional[str],
reply_to: Optional[str],
@ -127,12 +131,20 @@ def post(
poll_expires_in: int,
poll_multiple: bool,
poll_hide_totals: bool,
json: bool
json: bool,
using: str
):
"""Post a new status"""
if len(media) > 4:
raise click.ClickException("Cannot attach more than 4 files.")
if using:
user, app = config.get_user_app(using)
if not user or not app:
raise click.ClickException(f"Account '{using}' not found. Run `toot auth` to see available accounts.")
else:
user, app = ctx.user, ctx.app
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)
@ -141,8 +153,8 @@ def post(
raise click.ClickException("You must specify either text or media to post.")
response = api.post_status(
ctx.app,
ctx.user,
app,
user,
status_text,
visibility=visibility,
media_ids=media_ids,

View File

@ -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 import cli, get_context, json_option, pass_context, Context
from toot.cli import InstanceParamType, cli, get_context, json_option, pass_context, Context
@cli.command()
@ -43,7 +43,7 @@ def whois(ctx: Context, account: str, json: bool):
@cli.command()
@click.argument("instance", callback=validate_instance, required=False)
@click.argument("instance", type=InstanceParamType(), callback=validate_instance, required=False)
@json_option
def instance(instance: Optional[str], json: bool):
"""Display instance details

View File

@ -2,7 +2,7 @@ import sys
import click
from toot import api
from toot.cli import cli, get_context, pass_context, Context
from toot.cli import InstanceParamType, cli, get_context, pass_context, Context
from typing import Optional
from toot.cli.validators import validate_instance
@ -13,6 +13,7 @@ from toot.output import print_notifications, print_timeline
@cli.command()
@click.option(
"--instance", "-i",
type=InstanceParamType(),
callback=validate_instance,
help="""Domain or base URL of the instance from which to read,
e.g. 'mastodon.social' or 'https://mastodon.social'""",

View File

@ -1,7 +1,7 @@
import click
from typing import Optional
from toot.cli import TUI_COLORS, Context, cli, pass_context
from toot.cli import TUI_COLORS, VISIBILITY_CHOICES, Context, cli, pass_context
from toot.cli.validators import validate_tui_colors, validate_cache_size
from toot.tui.app import TUI, TuiOptions
@ -30,13 +30,25 @@ COLOR_OPTIONS = ", ".join(TUI_COLORS.keys())
help="""Specify the image cache maximum size in megabytes. Default: 10MB.
Minimum: 1MB."""
)
@click.option(
"-v", "--default-visibility",
type=click.Choice(VISIBILITY_CHOICES),
help="Default visibility when posting new toots; overrides the server-side preference"
)
@click.option(
"-S", "--always-show-sensitive",
is_flag=True,
help="Expand toots with content warnings automatically"
)
@pass_context
def tui(
ctx: Context,
colors: Optional[int],
media_viewer: Optional[str],
always_show_sensitive: bool,
relative_datetimes: bool,
cache_size: Optional[int],
default_visibility: Optional[str]
):
"""Launches the toot terminal user interface"""
if colors is None:
@ -47,6 +59,8 @@ def tui(
media_viewer=media_viewer,
relative_datetimes=relative_datetimes,
cache_size=cache_size,
default_visibility=default_visibility,
always_show_sensitive=always_show_sensitive,
)
tui = TUI.create(ctx.app, ctx.user, options)
tui.run()

View File

@ -38,7 +38,7 @@ def _get_error_message(response):
except Exception:
pass
return "Unknown error"
return f"Unknown error: {response.status_code} {response.reason}"
def process_response(response):
@ -81,6 +81,22 @@ def post(app, user, path, headers=None, files=None, data=None, json=None, allow_
return anon_post(url, headers=headers, files=files, data=data, json=json, allow_redirects=allow_redirects)
def anon_put(url, headers=None, files=None, data=None, json=None, allow_redirects=True):
request = Request(method="PUT", url=url, headers=headers, files=files, data=data, json=json)
response = send_request(request, allow_redirects)
return process_response(response)
def put(app, user, path, headers=None, files=None, data=None, json=None, allow_redirects=True):
url = app.base_url + path
headers = headers or {}
headers["Authorization"] = f"Bearer {user.access_token}"
return anon_put(url, headers=headers, files=files, data=data, json=json, allow_redirects=allow_redirects)
def patch(app, user, path, headers=None, files=None, data=None, json=None):
url = app.base_url + path

View File

@ -274,8 +274,9 @@ def print_notification(notification: Notification):
def print_notifications(notifications: List[Notification]):
for notification in notifications:
print_divider()
print_notification(notification)
if notification.type not in ['pleroma:emoji_reaction']:
print_divider()
print_notification(notification)
print_divider()

View File

@ -6,11 +6,13 @@ import warnings
from concurrent.futures import ThreadPoolExecutor
from typing import NamedTuple, Optional
from datetime import datetime, timezone
from toot import api, config, __version__, settings
from toot import App, User
from toot.cli import get_default_visibility
from toot.exceptions import ApiError
from toot.utils.datetime import parse_datetime
from .compose import StatusComposer
from .constants import PALETTE
@ -22,7 +24,8 @@ from .poll import Poll
from .timeline import Timeline
from .utils import get_max_toot_chars, parse_content_links, copy_to_clipboard, ImageCache
from PIL import Image
from .widgets import ModalBox
>>>>>>> master
logger = logging.getLogger(__name__)
@ -35,9 +38,10 @@ DEFAULT_MAX_TOOT_CHARS = 500
class TuiOptions(NamedTuple):
colors: int
media_viewer: Optional[str]
always_show_sensitive: bool
relative_datetimes: bool
cache_size: int
default_visibility: Optional[bool]
class Header(urwid.WidgetWrap):
def __init__(self, app, user):
@ -143,6 +147,7 @@ class TUI(urwid.Frame):
self.can_translate = False
self.account = None
self.followed_accounts = []
self.preferences = {}
if self.options.cache_size:
self.cache_max = 1024 * 1024 * self.options.cache_size
@ -153,6 +158,7 @@ class TUI(urwid.Frame):
def run(self):
self.loop.set_alarm_in(0, lambda *args: self.async_load_instance())
self.loop.set_alarm_in(0, lambda *args: self.async_load_preferences())
self.loop.set_alarm_in(0, lambda *args: self.async_load_timeline(
is_initial=True, timeline_name="home"))
self.loop.set_alarm_in(0, lambda *args: self.async_load_followed_accounts())
@ -337,6 +343,19 @@ class TUI(urwid.Frame):
return self.run_in_thread(_load_instance, done_callback=_done)
def async_load_preferences(self):
"""
Attempt to update user preferences from instance.
https://docs.joinmastodon.org/methods/preferences/
"""
def _load_preferences():
return api.get_preferences(self.app, self.user).json()
def _done(preferences):
self.preferences = preferences
return self.run_in_thread(_load_preferences, done_callback=_done)
def async_load_followed_accounts(self):
def _load_accounts():
try:
@ -411,11 +430,45 @@ class TUI(urwid.Frame):
def _post(timeline, *args):
self.post_status(*args)
composer = StatusComposer(self.max_toot_chars, self.user.username, in_reply_to)
# If the user specified --default-visibility, use that; otherwise,
# try to use the server-side default visibility. If that fails, fall
# back to get_default_visibility().
visibility = (self.options.default_visibility or
self.preferences.get('posting:default:visibility',
get_default_visibility()))
composer = StatusComposer(self.max_toot_chars, self.user.username,
visibility, in_reply_to)
urwid.connect_signal(composer, "close", _close)
urwid.connect_signal(composer, "post", _post)
self.open_overlay(composer, title="Compose status")
def async_edit(self, status):
def _fetch_source():
return api.fetch_status_source(self.app, self.user, status.id).json()
def _done(source):
self.close_overlay()
self.show_edit(status, source)
please_wait = ModalBox("Loading status...")
self.open_overlay(please_wait)
self.run_in_thread(_fetch_source, done_callback=_done)
def show_edit(self, status, source):
def _close(*args):
self.close_overlay()
def _edit(timeline, *args):
self.edit_status(status, *args)
composer = StatusComposer(self.max_toot_chars, self.user.username,
visibility=None, edit=status, source=source)
urwid.connect_signal(composer, "close", _close)
urwid.connect_signal(composer, "post", _edit)
self.open_overlay(composer, title="Edit status")
def show_goto_menu(self):
user_timelines = self.config.get("timelines", {})
user_lists = api.get_lists(self.app, self.user) or []
@ -563,6 +616,42 @@ class TUI(urwid.Frame):
self.footer.set_message("Status posted {} \\o/".format(status.id))
self.close_overlay()
def edit_status(self, status, content, warning, visibility, in_reply_to_id):
# We don't support editing polls (yet), so to avoid losing the poll
# data from the original toot, copy it to the edit request.
poll_args = {}
poll = status.original.data.get('poll', None)
if poll is not None:
poll_args['poll_options'] = [o['title'] for o in poll['options']]
poll_args['poll_multiple'] = poll['multiple']
# Convert absolute expiry time into seconds from now.
expires_at = parse_datetime(poll['expires_at'])
expires_in = int((expires_at - datetime.now(timezone.utc)).total_seconds())
poll_args['poll_expires_in'] = expires_in
if 'hide_totals' in poll:
poll_args['poll_hide_totals'] = poll['hide_totals']
data = api.edit_status(
self.app,
self.user,
status.id,
content,
spoiler_text=warning,
visibility=visibility,
**poll_args
).json()
new_status = self.make_status(data)
self.footer.set_message("Status edited {} \\o/".format(status.id))
self.close_overlay()
if self.timeline is not None:
self.timeline.update_status(new_status)
def show_account(self, account_id):
account = api.whois(self.app, self.user, account_id)
relationship = api.get_relationship(self.app, self.user, account_id)

View File

@ -1,8 +1,6 @@
import urwid
import logging
from toot.cli import get_default_visibility
from .constants import VISIBILITY_OPTIONS
from .widgets import Button, EditBox
@ -11,21 +9,22 @@ logger = logging.getLogger(__name__)
class StatusComposer(urwid.Frame):
"""
UI for compose and posting a status message.
UI for composing or editing a status message.
To edit a status, provide the original status in 'edit', and optionally
provide the status source (from the /status/:id/source API endpoint) in
'source'; this should have at least a 'text' member, and optionally
'spoiler_text'. If source is not provided, the formatted HTML will be
presented to the user for editing.
"""
signals = ["close", "post"]
def __init__(self, max_chars, username, in_reply_to=None):
def __init__(self, max_chars, username, visibility, in_reply_to=None,
edit=None, source=None):
self.in_reply_to = in_reply_to
self.max_chars = max_chars
self.username = username
text = self.get_initial_text(in_reply_to)
self.content_edit = EditBox(
edit_text=text, edit_pos=len(text), multiline=True, allow_tab=True)
urwid.connect_signal(self.content_edit.edit, "change", self.text_changed)
self.char_count = urwid.Text(["0/{}".format(max_chars)])
self.edit = edit
self.cw_edit = None
self.cw_add_button = Button("Add content warning",
@ -33,13 +32,34 @@ class StatusComposer(urwid.Frame):
self.cw_remove_button = Button("Remove content warning",
on_press=self.remove_content_warning)
self.visibility = (
in_reply_to.visibility if in_reply_to else get_default_visibility()
)
if edit:
if source is None:
text = edit.data["content"]
else:
text = source.get("text", edit.data["content"])
if 'spoiler_text' in source:
self.cw_edit = EditBox(multiline=True, allow_tab=True,
edit_text=source['spoiler_text'])
self.visibility = edit.data["visibility"]
else: # not edit
text = self.get_initial_text(in_reply_to)
self.visibility = (
in_reply_to.visibility if in_reply_to else visibility
)
self.content_edit = EditBox(
edit_text=text, edit_pos=len(text), multiline=True, allow_tab=True)
urwid.connect_signal(self.content_edit.edit, "change", self.text_changed)
self.char_count = urwid.Text(["0/{}".format(max_chars)])
self.visibility_button = Button("Visibility: {}".format(self.visibility),
on_press=self.choose_visibility)
self.post_button = Button("Post", on_press=self.post)
self.post_button = Button("Edit" if edit else "Post", on_press=self.post)
self.cancel_button = Button("Cancel", on_press=self.close)
contents = list(self.generate_list_items())

View File

@ -53,6 +53,10 @@ class Status:
self.id = self.data["id"]
self.account = self._get_account()
self.created_at = parse_datetime(data["created_at"])
if data["edited_at"]:
self.edited_at = parse_datetime(data["edited_at"])
else:
self.edited_at = None
self.author = self._get_author()
self.favourited = data.get("favourited", False)
self.reblogged = data.get("reblogged", False)

View File

@ -231,6 +231,7 @@ class Help(urwid.Padding):
yield urwid.Text(h(" [N] - Translate status if possible (toggle)"))
yield urwid.Text(h(" [R] - Reply to current status"))
yield urwid.Text(h(" [S] - Show text marked as sensitive"))
yield urwid.Text(h(" [M] - Show status media"))
yield urwid.Text(h(" [T] - Show status thread (replies)"))
yield urwid.Text(h(" [L] - Show the status links"))
yield urwid.Text(h(" [U] - Show the status data in JSON as received from the server"))

View File

@ -109,6 +109,7 @@ class Timeline(urwid.Columns):
"[A]ccount" if not status.is_mine else "",
"[B]oost",
"[D]elete" if status.is_mine else "",
"[E]dit" if status.is_mine else "",
"B[o]okmark",
"[F]avourite",
"[V]iew",
@ -198,6 +199,11 @@ class Timeline(urwid.Columns):
self.tui.show_delete_confirmation(status)
return
if key in ("e", "E"):
if status.is_mine:
self.tui.async_edit(status)
return
if key in ("f", "F"):
self.tui.async_toggle_favourite(self, status)
return
@ -349,6 +355,7 @@ class StatusDetails(urwid.Pile):
if self.status:
self.status.placeholders = []
self.followed_accounts = timeline.tui.followed_accounts
self.options = timeline.tui.options
reblogged_by = status.author if status and status.reblog else None
widget_list = list(self.content_generator(status.original, reblogged_by)
@ -447,9 +454,12 @@ class StatusDetails(urwid.Pile):
yield ("pack", urwid.Divider())
# Show content warning
if status.data["spoiler_text"] and not status.show_sensitive:
if status.data["spoiler_text"] and not status.show_sensitive and not self.options.always_show_sensitive:
yield ("pack", urwid.Text(("content_warning", "Marked as sensitive. Press S to view.")))
else:
if status.data["spoiler_text"]:
yield ("pack", urwid.Text(("content_warning", "Marked as sensitive.")))
content = status.original.translation if status.original.show_translation else status.data["content"]
widgetlist = html_to_widgets(content)
@ -516,6 +526,8 @@ class StatusDetails(urwid.Pile):
yield ("pack", urwid.Text([
("status_detail_timestamp", f"{status.created_at.strftime('%Y-%m-%d %H:%M')} "),
("status_detail_timestamp",
f"(edited {status.edited_at.strftime('%Y-%m-%d %H:%M')}) " if status.edited_at else ""),
("status_detail_bookmarked" if status.bookmarked else "dim", "b "),
("dim", f"{status.data['replies_count']} "),
("highlight" if status.reblogged else "dim", f"{status.data['reblogs_count']} "),
@ -579,7 +591,7 @@ class StatusDetails(urwid.Pile):
class StatusListItem(SelectableColumns):
def __init__(self, status, relative_datetimes):
edited_at = status.data.get("edited_at")
edited_at = status.original.edited_at
# TODO: hacky implementation to avoid creating conflicts for existing
# pull reuqests, refactor when merged.
@ -593,7 +605,7 @@ class StatusListItem(SelectableColumns):
favourited = ("highlight", "") if status.original.favourited else " "
reblogged = ("highlight", "") if status.original.reblogged else " "
is_reblog = ("dim", "") if status.reblog else " "
is_reply = ("dim", "") if status.original.in_reply_to else " "
is_reply = ("dim", " ") if status.original.in_reply_to else " "
return super().__init__([
("pack", SelectableText(("status_list_timestamp", created_at), wrap="clip")),

View File

@ -159,3 +159,10 @@ class EmojiText(urwid.Padding):
columns.append(("weight", 9999, urwid.Text("")))
column_widget = urwid.Columns(columns, dividechars=1, min_width=2)
super().__init__(column_widget)
class ModalBox(urwid.Frame):
def __init__(self, message):
text = urwid.Text(message)
filler = urwid.Filler(text, valign='top', top=1, bottom=1)
padding = urwid.Padding(filler, left=1, right=1)
return super().__init__(padding)