Porównaj commity

...

15 Commity

Autor SHA1 Wiadomość Data
Ivan Habunek 4b17e2e586
Merge pull request #473 from danschwarz/corrupt_link_fix
Added safeguards to prevent crashes when rendering corrupt URLs
2024-03-12 14:54:51 +01:00
Daniel Schwarz 20968fe87f Added safeguards to prevent crashes when rendering corrupt URLs 2024-03-09 13:48:33 -05:00
Ivan Habunek 3bac9b2fb6
Add changelog, bump version 2024-03-09 12:12:57 +01:00
Ivan Habunek 3420f1466a
Fix type annotation 2024-03-09 12:12:27 +01:00
Ivan Habunek 3eebbe35c9
Change option to lowercase 2024-03-09 10:16:41 +01:00
Ivan Habunek 4d5ac3cc4e
Don't break if status doesn't have edited_at 2024-03-09 10:13:34 +01:00
Ivan Habunek ee98ce3746
Fix following tests 2024-03-09 09:54:46 +01:00
Ivan Habunek 0cbb8863b3
Start some docs for testing 2024-03-09 09:43:02 +01:00
Ivan Habunek 1709a416b3
Make list printing not break on akkoma 2024-03-09 09:32:38 +01:00
Ivan Habunek f324aa119d
Add List entity 2024-03-09 09:32:04 +01:00
Ivan Habunek 43f51cbbb9
Make tests a bit more robust
By creating a new user we don't need to check if we're following or
blocking them before running the test.
2024-03-09 09:24:00 +01:00
Ivan Habunek 225dfbfb2e
Use alias for types 2024-03-09 09:20:43 +01:00
Ivan Habunek 9ae205c548
Upload media using same user in toot post --using 2024-02-10 18:24:35 +01:00
Ivan Habunek 9875209b30
Improve types 2024-02-10 18:24:35 +01:00
Ivan Habunek 965ffa1312
Remove unused code 2024-02-10 18:24:34 +01:00
18 zmienionych plików z 181 dodań i 108 usunięć

Wyświetl plik

@ -3,6 +3,13 @@ Changelog
<!-- Do not edit. This file is automatically generated from changelog.yaml.-->
**0.42.0 (2024-03-09)**
* TUI: Add `toot tui --always-show-sensitive` option (thanks Lexi Winter)
* TUI: Document missing shortcuts (thanks Denis Laxalde)
* TUI: Use rounded boxes for nicer visuals (thanks Dan Schwarz)
* TUI: Don't break if edited_at status field does not exist
**0.41.1 (2024-01-02)**
* Fix a crash in settings parsing code

Wyświetl plik

@ -1,3 +1,11 @@
0.42.0:
date: 2024-03-09
changes:
- "TUI: Add `toot tui --always-show-sensitive` option (thanks Lexi Winter)"
- "TUI: Document missing shortcuts (thanks Denis Laxalde)"
- "TUI: Use rounded boxes for nicer visuals (thanks Dan Schwarz)"
- "TUI: Don't break if edited_at status field does not exist"
0.41.1:
date: 2024-01-02
changes:

Wyświetl plik

@ -3,6 +3,13 @@ Changelog
<!-- Do not edit. This file is automatically generated from changelog.yaml.-->
**0.42.0 (2024-03-09)**
* TUI: Add `toot tui --always-show-sensitive` option (thanks Lexi Winter)
* TUI: Document missing shortcuts (thanks Denis Laxalde)
* TUI: Use rounded boxes for nicer visuals (thanks Dan Schwarz)
* TUI: Don't break if edited_at status field does not exist
**0.41.1 (2024-01-02)**
* Fix a crash in settings parsing code

Wyświetl plik

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

42
tests/README.md 100644
Wyświetl plik

@ -0,0 +1,42 @@
Testing toot
============
This document is WIP.
Mastodon
--------
TODO
Pleroma
-------
TODO
Akkoma
------
Install using the guide here:
https://docs.akkoma.dev/stable/installation/docker_en/
Disable captcha and throttling by adding this to `config/prod.exs`:
```ex
# Disable captcha for testing
config :pleroma, Pleroma.Captcha,
enabled: false
# Disable rate limiting for testing
config :pleroma, :rate_limit,
authentication: nil,
timeline: nil,
search: nil,
app_account_creation: nil,
relations_actions: nil,
relation_id_action: nil,
statuses_actions: nil,
status_id_action: nil,
password_reset: nil,
account_confirmation_resend: nil,
ap_routes: nil
```

Wyświetl plik

@ -1,4 +1,5 @@
import json
from tests.integration.conftest import register_account
from toot import App, User, api, cli
from toot.entities import Account, Relationship, from_dict
@ -35,9 +36,8 @@ def test_whois(app: App, friend: User, run):
assert f"@{friend.username}" in result.stdout
def test_following(app: App, user: User, friend: User, friend_id, run):
# Make sure we're not initially following friend
api.unfollow(app, user, friend_id)
def test_following(app: App, user: User, run):
friend = register_account(app)
result = run(cli.accounts.following, user.username)
assert result.exit_code == 0
@ -84,9 +84,8 @@ def test_following_not_found(run):
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 initially following friend
api.unfollow(app, user, friend_id)
def test_following_json(app: App, user: User, user_id, run_json):
friend = register_account(app)
result = run_json(cli.accounts.following, user.username, "--json")
assert result == []
@ -96,24 +95,26 @@ def test_following_json(app: App, user: User, friend: User, user_id, friend_id,
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.accounts.following, user.username, "--json")
relationship = from_dict(Relationship, result)
assert relationship.id == friend_id
account = from_dict(Account, result)
assert account.acct == friend.username
# If no account is given defaults to logged in user
[result] = run_json(cli.accounts.following, user.username, "--json")
relationship = from_dict(Relationship, result)
assert relationship.id == friend_id
[result] = run_json(cli.accounts.following, "--json")
account = from_dict(Account, result)
assert account.acct == friend.username
assert relationship.following is True
[result] = run_json(cli.accounts.followers, friend.username, "--json")
assert result["id"] == user_id
account = from_dict(Account, result)
assert account.acct == user.username
result = run_json(cli.accounts.unfollow, friend.username, "--json")
assert result["id"] == friend_id
assert result["following"] is False
relationship = from_dict(Relationship, result)
assert relationship.following is False
result = run_json(cli.accounts.following, user.username, "--json")
assert result == []
@ -200,9 +201,8 @@ def test_mute_json(app: App, user: User, friend: User, run_json, friend_id):
assert result == []
def test_block(app, user, friend, friend_id, run):
# Make sure we're not initially blocking friend
api.unblock(app, user, friend_id)
def test_block(app, user, run):
friend = register_account(app)
result = run(cli.accounts.blocked)
assert result.exit_code == 0

Wyświetl plik

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

Wyświetl plik

@ -3,6 +3,7 @@ import json as pyjson
from toot import api, config
from toot.cli import Context, cli, pass_context, json_option
from toot.entities import from_dict_list, List
from toot.output import print_list_accounts, print_lists, print_warning
@ -18,7 +19,8 @@ def lists(ctx: click.Context):
if not user or not app:
raise click.ClickException("This command requires you to be logged in.")
lists = api.get_lists(app, user)
data = api.get_lists(app, user)
lists = from_dict_list(List, data)
if lists:
print_lists(lists)
else:
@ -30,12 +32,13 @@ def lists(ctx: click.Context):
@pass_context
def list(ctx: Context, json: bool):
"""List all your lists"""
lists = api.get_lists(ctx.app, ctx.user)
data = api.get_lists(ctx.app, ctx.user)
if json:
click.echo(pyjson.dumps(lists))
click.echo(pyjson.dumps(data))
else:
if lists:
if data:
lists = from_dict_list(List, data)
print_lists(lists)
else:
click.echo("You have no lists defined.")

Wyświetl plik

@ -145,7 +145,7 @@ def post(
else:
user, app = ctx.user, ctx.app
media_ids = _upload_media(ctx.app, ctx.user, media, descriptions, thumbnails)
media_ids = _upload_media(app, user, media, descriptions, thumbnails)
status_text = _get_status_text(text, editor, media)
scheduled_at = _get_scheduled_at(scheduled_at, scheduled_in)

Wyświetl plik

@ -30,7 +30,7 @@ COLOR_OPTIONS = ", ".join(TUI_COLORS.keys())
help="Default visibility when posting new toots; overrides the server-side preference"
)
@click.option(
"-S", "--always-show-sensitive",
"-s", "--always-show-sensitive",
is_flag=True,
help="Expand toots with content warnings automatically"
)

Wyświetl plik

@ -17,11 +17,11 @@ def get_config_file_path():
return join(get_config_dir(), TOOT_CONFIG_FILE_NAME)
def user_id(user):
def user_id(user: User):
return "{}@{}".format(user.username, user.instance)
def make_config(path):
def make_config(path: str):
"""Creates an empty toot configuration file."""
config = {
"apps": {},
@ -58,7 +58,7 @@ def save_config(config):
return json.dump(config, f, indent=True, sort_keys=True)
def extract_user_app(config, user_id):
def extract_user_app(config, user_id: str):
if user_id not in config['users']:
return None, None
@ -82,7 +82,7 @@ def get_active_user_app():
return None, None
def get_user_app(user_id):
def get_user_app(user_id: str):
"""Returns (User, App) for given user ID or (None, None) if user is not logged in."""
return extract_user_app(load_config(), user_id)
@ -93,7 +93,7 @@ def load_app(instance: str) -> Optional[App]:
return App(**config['apps'][instance])
def load_user(user_id, throw=False):
def load_user(user_id: str, throw=False):
config = load_config()
if user_id in config['users']:
@ -120,7 +120,7 @@ def save_app(app: App):
config['apps'][app.instance] = app._asdict()
def delete_app(config, app):
def delete_app(config, app: App):
with edit_config() as config:
config['apps'].pop(app.instance, None)

Wyświetl plik

@ -9,11 +9,12 @@ different versions of the Mastodon API.
"""
import dataclasses
import typing as t
from dataclasses import dataclass, is_dataclass
from datetime import date, datetime
from functools import lru_cache
from typing import Any, Dict, List, Optional, Tuple, Type, TypeVar, Union
from typing import Any, Dict, Optional, Tuple, Type, TypeVar, Union
from typing import get_type_hints
from toot.typing_compat import get_args, get_origin
@ -59,8 +60,8 @@ class Account:
header: str
header_static: str
locked: bool
fields: List[AccountField]
emojis: List[CustomEmoji]
fields: t.List[AccountField]
emojis: t.List[CustomEmoji]
bot: bool
group: bool
discoverable: Optional[bool]
@ -154,10 +155,10 @@ class Poll:
multiple: bool
votes_count: int
voters_count: Optional[int]
options: List[PollOption]
emojis: List[CustomEmoji]
options: t.List[PollOption]
emojis: t.List[CustomEmoji]
voted: Optional[bool]
own_votes: Optional[List[int]]
own_votes: Optional[t.List[int]]
@dataclass
@ -207,11 +208,11 @@ class Filter:
"""
id: str
title: str
context: List[str]
context: t.List[str]
expires_at: Optional[datetime]
filter_action: str
keywords: List[FilterKeyword]
statuses: List[FilterStatus]
keywords: t.List[FilterKeyword]
statuses: t.List[FilterStatus]
@dataclass
@ -220,7 +221,7 @@ class FilterResult:
https://docs.joinmastodon.org/entities/FilterResult/
"""
filter: Filter
keyword_matches: Optional[List[str]]
keyword_matches: Optional[t.List[str]]
status_matches: Optional[str]
@ -237,11 +238,11 @@ class Status:
visibility: str
sensitive: bool
spoiler_text: str
media_attachments: List[MediaAttachment]
media_attachments: t.List[MediaAttachment]
application: Optional[Application]
mentions: List[StatusMention]
tags: List[StatusTag]
emojis: List[CustomEmoji]
mentions: t.List[StatusMention]
tags: t.List[StatusTag]
emojis: t.List[CustomEmoji]
reblogs_count: int
favourites_count: int
replies_count: int
@ -259,7 +260,7 @@ class Status:
muted: Optional[bool]
bookmarked: Optional[bool]
pinned: Optional[bool]
filtered: Optional[List[FilterResult]]
filtered: Optional[t.List[FilterResult]]
@property
def original(self) -> "Status":
@ -289,8 +290,8 @@ class Report:
comment: str
forwarded: bool
created_at: datetime
status_ids: Optional[List[str]]
rule_ids: Optional[List[str]]
status_ids: Optional[t.List[str]]
rule_ids: Optional[t.List[str]]
target_account: Account
@ -328,7 +329,7 @@ class InstanceConfigurationStatuses:
@dataclass
class InstanceConfigurationMediaAttachments:
supported_mime_types: List[str]
supported_mime_types: t.List[str]
image_size_limit: int
image_matrix_limit: int
video_size_limit: int
@ -377,13 +378,13 @@ class Instance:
urls: InstanceUrls
stats: InstanceStats
thumbnail: Optional[str]
languages: List[str]
languages: t.List[str]
registrations: bool
approval_required: bool
invites_enabled: bool
configuration: InstanceConfiguration
contact_account: Optional[Account]
rules: List[Rule]
rules: t.List[Rule]
@dataclass
@ -397,7 +398,7 @@ class Relationship:
following: bool
showing_reblogs: bool
notifying: bool
languages: List[str]
languages: t.List[str]
followed_by: bool
blocking: bool
blocked_by: bool
@ -428,7 +429,7 @@ class Tag:
"""
name: str
url: str
history: List[TagHistory]
history: t.List[TagHistory]
following: Optional[bool]
@ -445,6 +446,19 @@ class FeaturedTag:
last_status_at: datetime
@dataclass
class List:
"""
Represents a list of some users that the authenticated user follows.
https://docs.joinmastodon.org/entities/List/
"""
id: str
title: str
# This is a required field on Mastodon, but not supported on Pleroma/Akkoma
# see: https://git.pleroma.social/pleroma/pleroma/-/issues/2918
replies_policy: Optional[str]
# Generic data class instance
T = TypeVar("T")
@ -481,7 +495,7 @@ def from_dict(cls: Type[T], data: Dict) -> T:
@lru_cache(maxsize=100)
def get_fields(cls: Type) -> List[Tuple[str, Type, Any]]:
def get_fields(cls: Type) -> t.List[Tuple[str, Type, Any]]:
hints = get_type_hints(cls)
return [
(
@ -493,7 +507,7 @@ def get_fields(cls: Type) -> List[Tuple[str, Type, Any]]:
]
def from_dict_list(cls: Type[T], data: List[Dict]) -> List[T]:
def from_dict_list(cls: Type[T], data: t.List[Dict]) -> t.List[T]:
return [from_dict(cls, x) for x in data]

Wyświetl plik

@ -1,12 +1,12 @@
import click
import re
import textwrap
import shutil
import textwrap
import typing as t
from toot.entities import Account, Instance, Notification, Poll, Status
from toot.entities import Account, Instance, Notification, Poll, Status, List
from toot.utils import get_text, html_to_paragraphs
from toot.wcstring import wc_wrap
from typing import Any, Generator, Iterable, List
from wcwidth import wcswidth
@ -38,7 +38,7 @@ def instance_to_text(instance: Instance, width: int) -> str:
return "\n".join(instance_lines(instance, width))
def instance_lines(instance: Instance, width: int) -> Generator[str, None, None]:
def instance_lines(instance: Instance, width: int) -> t.Generator[str, None, None]:
yield f"{green(instance.title)}"
yield f"{blue(instance.uri)}"
yield f"running Mastodon {instance.version}"
@ -78,7 +78,7 @@ 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]:
def account_lines(account: Account, width: int) -> t.Generator[str, None, None]:
acct = f"@{account.acct}"
since = account.created_at.strftime("%Y-%m-%d")
@ -119,13 +119,13 @@ def print_tag_list(tags):
click.echo(f"* {format_tag_name(tag)}\t{tag['url']}")
def print_lists(lists):
def print_lists(lists: t.List[List]):
headers = ["ID", "Title", "Replies"]
data = [[lst["id"], lst["title"], lst["replies_policy"]] for lst in lists]
data = [[lst.id, lst.title, lst.replies_policy or ""] for lst in lists]
print_table(headers, data)
def print_table(headers: List[str], data: List[List[str]]):
def print_table(headers: t.List[str], data: t.List[t.List[str]]):
widths = [[len(cell) for cell in row] for row in data + [headers]]
widths = [max(width) for width in zip(*widths)]
@ -178,7 +178,7 @@ def status_to_text(status: Status, width: int) -> str:
return "\n".join(status_lines(status))
def status_lines(status: Status) -> Generator[str, None, None]:
def status_lines(status: Status) -> t.Generator[str, None, None]:
width = get_width()
status_id = status.id
in_reply_to_id = status.in_reply_to_id
@ -222,7 +222,7 @@ def status_lines(status: Status) -> Generator[str, None, None]:
yield f"ID {yellow(status_id)} {reply} {boost}"
def html_lines(html: str, width: int) -> Generator[str, None, None]:
def html_lines(html: str, width: int) -> t.Generator[str, None, None]:
first = True
for paragraph in html_to_paragraphs(html):
if not first:
@ -233,7 +233,7 @@ def html_lines(html: str, width: int) -> Generator[str, None, None]:
first = False
def poll_lines(poll: Poll) -> Generator[str, None, None]:
def poll_lines(poll: Poll) -> t.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)
@ -258,7 +258,7 @@ def poll_lines(poll: Poll) -> Generator[str, None, None]:
yield poll_footer
def print_timeline(items: Iterable[Status]):
def print_timeline(items: t.Iterable[Status]):
print_divider()
for item in items:
print_status(item)
@ -272,7 +272,7 @@ def print_notification(notification: Notification):
print_status(notification.status)
def print_notifications(notifications: List[Notification]):
def print_notifications(notifications: t.List[Notification]):
for notification in notifications:
if notification.type not in ['pleroma:emoji_reaction']:
print_divider()
@ -316,25 +316,25 @@ def format_account_name(account: Account) -> str:
# Shorthand functions for coloring output
def blue(text: Any) -> str:
def blue(text: t.Any) -> str:
return click.style(text, fg="blue")
def bold(text: Any) -> str:
def bold(text: t.Any) -> str:
return click.style(text, bold=True)
def cyan(text: Any) -> str:
def cyan(text: t.Any) -> str:
return click.style(text, fg="cyan")
def dim(text: Any) -> str:
def dim(text: t.Any) -> str:
return click.style(text, dim=True)
def green(text: Any) -> str:
def green(text: t.Any) -> str:
return click.style(text, fg="green")
def yellow(text: Any) -> str:
def yellow(text: t.Any) -> str:
return click.style(text, fg="yellow")

Wyświetl plik

@ -35,7 +35,7 @@ class TuiOptions(NamedTuple):
media_viewer: Optional[str]
always_show_sensitive: bool
relative_datetimes: bool
default_visibility: Optional[bool]
default_visibility: Optional[str]
class Header(urwid.WidgetWrap):

Wyświetl plik

@ -53,7 +53,7 @@ class Status:
self.id = self.data["id"]
self.account = self._get_account()
self.created_at = parse_datetime(data["created_at"])
if data["edited_at"]:
if data.get("edited_at"):
self.edited_at = parse_datetime(data["edited_at"])
else:
self.edited_at = None

Wyświetl plik

@ -60,7 +60,10 @@ def html_to_widgets(html, recovery_attempt=False) -> List[urwid.Widget]:
def url_to_widget(url: str):
widget = len(url), urwid.Filler(Hyperlink(url, "link", url))
try:
widget = len(url), urwid.Filler(Hyperlink(url, "link", url))
except ValueError:
widget = len(url), urwid.Filler(urwid.Text(url)) # don't style as link
return TextEmbed(widget)
@ -98,10 +101,16 @@ def text_to_widget(attr, markup) -> urwid.Widget:
if match:
label, url = match.groups()
anchor_attr = get_best_anchor_attr(attr_list)
markup_list.append((
len(label),
urwid.Filler(Hyperlink(url, anchor_attr, label)),
))
try:
markup_list.append((
len(label),
urwid.Filler(Hyperlink(url, anchor_attr, label)),
))
except ValueError:
markup_list.append((
len(label),
urwid.Filler(urwid.Text(url)), # don't style as link
))
else:
markup_list.append(run)
else:

Wyświetl plik

@ -1,26 +1,22 @@
import click
import os
import re
import socket
import subprocess
import tempfile
import unicodedata
import warnings
from bs4 import BeautifulSoup
from typing import Any, Dict, List
import click
from toot.exceptions import ConsoleError
from typing import Any, Dict, Generator, List, Optional
from urllib.parse import urlparse, urlencode, quote, unquote
def str_bool(b):
def str_bool(b: bool) -> str:
"""Convert boolean to string, in the way expected by the API."""
return "true" if b else "false"
def str_bool_nullable(b):
def str_bool_nullable(b: Optional[bool]) -> Optional[str]:
"""Similar to str_bool, but leave None as None"""
return None if b is None else str_bool(b)
@ -34,7 +30,7 @@ def parse_html(html: str) -> BeautifulSoup:
return BeautifulSoup(html.replace("&apos;", "'"), "html.parser")
def get_text(html):
def get_text(html: str) -> str:
"""Converts html to text, strips all tags."""
text = parse_html(html).get_text()
return unicodedata.normalize("NFKC", text)
@ -53,7 +49,7 @@ def html_to_paragraphs(html: str) -> List[List[str]]:
return [[get_text(line) for line in p] for p in paragraphs]
def format_content(content):
def format_content(content: str) -> Generator[str, None, None]:
"""Given a Status contents in HTML, converts it into lines of plain text.
Returns a generator yielding lines of content.
@ -73,25 +69,12 @@ def format_content(content):
first = False
def domain_exists(name):
try:
socket.gethostbyname(name)
return True
except OSError:
return False
def assert_domain_exists(domain):
if not domain_exists(domain):
raise ConsoleError("Domain {} not found".format(domain))
EOF_KEY = "Ctrl-Z" if os.name == 'nt' else "Ctrl-D"
def multiline_input():
def multiline_input() -> str:
"""Lets user input multiple lines of text, terminated by EOF."""
lines = []
lines: List[str] = []
while True:
try:
lines.append(input())

Wyświetl plik

@ -4,7 +4,7 @@ import os
from datetime import datetime, timezone
def parse_datetime(value):
def parse_datetime(value: str) -> datetime:
"""Returns an aware datetime in local timezone"""
dttm = datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%f%z")