kopia lustrzana https://github.com/ihabunek/toot
Porównaj commity
15 Commity
e1be3a68bb
...
4b17e2e586
Autor | SHA1 | Data |
---|---|---|
Ivan Habunek | 4b17e2e586 | |
Daniel Schwarz | 20968fe87f | |
Ivan Habunek | 3bac9b2fb6 | |
Ivan Habunek | 3420f1466a | |
Ivan Habunek | 3eebbe35c9 | |
Ivan Habunek | 4d5ac3cc4e | |
Ivan Habunek | ee98ce3746 | |
Ivan Habunek | 0cbb8863b3 | |
Ivan Habunek | 1709a416b3 | |
Ivan Habunek | f324aa119d | |
Ivan Habunek | 43f51cbbb9 | |
Ivan Habunek | 225dfbfb2e | |
Ivan Habunek | 9ae205c548 | |
Ivan Habunek | 9875209b30 | |
Ivan Habunek | 965ffa1312 |
|
@ -3,6 +3,13 @@ Changelog
|
||||||
|
|
||||||
<!-- Do not edit. This file is automatically generated from changelog.yaml.-->
|
<!-- 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)**
|
**0.41.1 (2024-01-02)**
|
||||||
|
|
||||||
* Fix a crash in settings parsing code
|
* Fix a crash in settings parsing code
|
||||||
|
|
|
@ -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:
|
0.41.1:
|
||||||
date: 2024-01-02
|
date: 2024-01-02
|
||||||
changes:
|
changes:
|
||||||
|
|
|
@ -3,6 +3,13 @@ Changelog
|
||||||
|
|
||||||
<!-- Do not edit. This file is automatically generated from changelog.yaml.-->
|
<!-- 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)**
|
**0.41.1 (2024-01-02)**
|
||||||
|
|
||||||
* Fix a crash in settings parsing code
|
* Fix a crash in settings parsing code
|
||||||
|
|
2
setup.py
2
setup.py
|
@ -12,7 +12,7 @@ and blocking accounts and other actions.
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
name='toot',
|
name='toot',
|
||||||
version='0.41.1',
|
version='0.42.0',
|
||||||
description='Mastodon CLI client',
|
description='Mastodon CLI client',
|
||||||
long_description=long_description.strip(),
|
long_description=long_description.strip(),
|
||||||
author='Ivan Habunek',
|
author='Ivan Habunek',
|
||||||
|
|
|
@ -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
|
||||||
|
```
|
|
@ -1,4 +1,5 @@
|
||||||
import json
|
import json
|
||||||
|
from tests.integration.conftest import register_account
|
||||||
|
|
||||||
from toot import App, User, api, cli
|
from toot import App, User, api, cli
|
||||||
from toot.entities import Account, Relationship, from_dict
|
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
|
assert f"@{friend.username}" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
def test_following(app: App, user: User, friend: User, friend_id, run):
|
def test_following(app: App, user: User, run):
|
||||||
# Make sure we're not initially following friend
|
friend = register_account(app)
|
||||||
api.unfollow(app, user, friend_id)
|
|
||||||
|
|
||||||
result = run(cli.accounts.following, user.username)
|
result = run(cli.accounts.following, user.username)
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
@ -84,9 +84,8 @@ def test_following_not_found(run):
|
||||||
assert result.stderr.strip() == "Error: Account not found"
|
assert result.stderr.strip() == "Error: Account not found"
|
||||||
|
|
||||||
|
|
||||||
def test_following_json(app: App, user: User, friend: User, user_id, friend_id, run_json):
|
def test_following_json(app: App, user: User, user_id, run_json):
|
||||||
# Make sure we're not initially following friend
|
friend = register_account(app)
|
||||||
api.unfollow(app, user, friend_id)
|
|
||||||
|
|
||||||
result = run_json(cli.accounts.following, user.username, "--json")
|
result = run_json(cli.accounts.following, user.username, "--json")
|
||||||
assert result == []
|
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")
|
result = run_json(cli.accounts.follow, friend.username, "--json")
|
||||||
relationship = from_dict(Relationship, result)
|
relationship = from_dict(Relationship, result)
|
||||||
assert relationship.id == friend_id
|
|
||||||
assert relationship.following is True
|
assert relationship.following is True
|
||||||
|
|
||||||
[result] = run_json(cli.accounts.following, user.username, "--json")
|
[result] = run_json(cli.accounts.following, user.username, "--json")
|
||||||
relationship = from_dict(Relationship, result)
|
account = from_dict(Account, result)
|
||||||
assert relationship.id == friend_id
|
assert account.acct == friend.username
|
||||||
|
|
||||||
# If no account is given defaults to logged in user
|
# If no account is given defaults to logged in user
|
||||||
[result] = run_json(cli.accounts.following, user.username, "--json")
|
[result] = run_json(cli.accounts.following, "--json")
|
||||||
relationship = from_dict(Relationship, result)
|
account = from_dict(Account, result)
|
||||||
assert relationship.id == friend_id
|
assert account.acct == friend.username
|
||||||
|
|
||||||
|
assert relationship.following is True
|
||||||
|
|
||||||
[result] = run_json(cli.accounts.followers, friend.username, "--json")
|
[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")
|
result = run_json(cli.accounts.unfollow, friend.username, "--json")
|
||||||
assert result["id"] == friend_id
|
relationship = from_dict(Relationship, result)
|
||||||
assert result["following"] is False
|
assert relationship.following is False
|
||||||
|
|
||||||
result = run_json(cli.accounts.following, user.username, "--json")
|
result = run_json(cli.accounts.following, user.username, "--json")
|
||||||
assert result == []
|
assert result == []
|
||||||
|
@ -200,9 +201,8 @@ def test_mute_json(app: App, user: User, friend: User, run_json, friend_id):
|
||||||
assert result == []
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
def test_block(app, user, friend, friend_id, run):
|
def test_block(app, user, run):
|
||||||
# Make sure we're not initially blocking friend
|
friend = register_account(app)
|
||||||
api.unblock(app, user, friend_id)
|
|
||||||
|
|
||||||
result = run(cli.accounts.blocked)
|
result = run(cli.accounts.blocked)
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
|
|
@ -4,7 +4,7 @@ import sys
|
||||||
from os.path import join, expanduser
|
from os.path import join, expanduser
|
||||||
from typing import NamedTuple
|
from typing import NamedTuple
|
||||||
|
|
||||||
__version__ = '0.41.1'
|
__version__ = '0.42.0'
|
||||||
|
|
||||||
|
|
||||||
class App(NamedTuple):
|
class App(NamedTuple):
|
||||||
|
|
|
@ -3,6 +3,7 @@ import json as pyjson
|
||||||
|
|
||||||
from toot import api, config
|
from toot import api, config
|
||||||
from toot.cli import Context, cli, pass_context, json_option
|
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
|
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:
|
if not user or not app:
|
||||||
raise click.ClickException("This command requires you to be logged in.")
|
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:
|
if lists:
|
||||||
print_lists(lists)
|
print_lists(lists)
|
||||||
else:
|
else:
|
||||||
|
@ -30,12 +32,13 @@ def lists(ctx: click.Context):
|
||||||
@pass_context
|
@pass_context
|
||||||
def list(ctx: Context, json: bool):
|
def list(ctx: Context, json: bool):
|
||||||
"""List all your lists"""
|
"""List all your lists"""
|
||||||
lists = api.get_lists(ctx.app, ctx.user)
|
data = api.get_lists(ctx.app, ctx.user)
|
||||||
|
|
||||||
if json:
|
if json:
|
||||||
click.echo(pyjson.dumps(lists))
|
click.echo(pyjson.dumps(data))
|
||||||
else:
|
else:
|
||||||
if lists:
|
if data:
|
||||||
|
lists = from_dict_list(List, data)
|
||||||
print_lists(lists)
|
print_lists(lists)
|
||||||
else:
|
else:
|
||||||
click.echo("You have no lists defined.")
|
click.echo("You have no lists defined.")
|
||||||
|
|
|
@ -145,7 +145,7 @@ def post(
|
||||||
else:
|
else:
|
||||||
user, app = ctx.user, ctx.app
|
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)
|
status_text = _get_status_text(text, editor, media)
|
||||||
scheduled_at = _get_scheduled_at(scheduled_at, scheduled_in)
|
scheduled_at = _get_scheduled_at(scheduled_at, scheduled_in)
|
||||||
|
|
||||||
|
|
|
@ -30,7 +30,7 @@ COLOR_OPTIONS = ", ".join(TUI_COLORS.keys())
|
||||||
help="Default visibility when posting new toots; overrides the server-side preference"
|
help="Default visibility when posting new toots; overrides the server-side preference"
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"-S", "--always-show-sensitive",
|
"-s", "--always-show-sensitive",
|
||||||
is_flag=True,
|
is_flag=True,
|
||||||
help="Expand toots with content warnings automatically"
|
help="Expand toots with content warnings automatically"
|
||||||
)
|
)
|
||||||
|
|
|
@ -17,11 +17,11 @@ def get_config_file_path():
|
||||||
return join(get_config_dir(), TOOT_CONFIG_FILE_NAME)
|
return join(get_config_dir(), TOOT_CONFIG_FILE_NAME)
|
||||||
|
|
||||||
|
|
||||||
def user_id(user):
|
def user_id(user: User):
|
||||||
return "{}@{}".format(user.username, user.instance)
|
return "{}@{}".format(user.username, user.instance)
|
||||||
|
|
||||||
|
|
||||||
def make_config(path):
|
def make_config(path: str):
|
||||||
"""Creates an empty toot configuration file."""
|
"""Creates an empty toot configuration file."""
|
||||||
config = {
|
config = {
|
||||||
"apps": {},
|
"apps": {},
|
||||||
|
@ -58,7 +58,7 @@ def save_config(config):
|
||||||
return json.dump(config, f, indent=True, sort_keys=True)
|
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']:
|
if user_id not in config['users']:
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
@ -82,7 +82,7 @@ def get_active_user_app():
|
||||||
return None, None
|
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."""
|
"""Returns (User, App) for given user ID or (None, None) if user is not logged in."""
|
||||||
return extract_user_app(load_config(), user_id)
|
return extract_user_app(load_config(), user_id)
|
||||||
|
|
||||||
|
@ -93,7 +93,7 @@ def load_app(instance: str) -> Optional[App]:
|
||||||
return App(**config['apps'][instance])
|
return App(**config['apps'][instance])
|
||||||
|
|
||||||
|
|
||||||
def load_user(user_id, throw=False):
|
def load_user(user_id: str, throw=False):
|
||||||
config = load_config()
|
config = load_config()
|
||||||
|
|
||||||
if user_id in config['users']:
|
if user_id in config['users']:
|
||||||
|
@ -120,7 +120,7 @@ def save_app(app: App):
|
||||||
config['apps'][app.instance] = app._asdict()
|
config['apps'][app.instance] = app._asdict()
|
||||||
|
|
||||||
|
|
||||||
def delete_app(config, app):
|
def delete_app(config, app: App):
|
||||||
with edit_config() as config:
|
with edit_config() as config:
|
||||||
config['apps'].pop(app.instance, None)
|
config['apps'].pop(app.instance, None)
|
||||||
|
|
||||||
|
|
|
@ -9,11 +9,12 @@ different versions of the Mastodon API.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import dataclasses
|
import dataclasses
|
||||||
|
import typing as t
|
||||||
|
|
||||||
from dataclasses import dataclass, is_dataclass
|
from dataclasses import dataclass, is_dataclass
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from functools import lru_cache
|
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 typing import get_type_hints
|
||||||
|
|
||||||
from toot.typing_compat import get_args, get_origin
|
from toot.typing_compat import get_args, get_origin
|
||||||
|
@ -59,8 +60,8 @@ class Account:
|
||||||
header: str
|
header: str
|
||||||
header_static: str
|
header_static: str
|
||||||
locked: bool
|
locked: bool
|
||||||
fields: List[AccountField]
|
fields: t.List[AccountField]
|
||||||
emojis: List[CustomEmoji]
|
emojis: t.List[CustomEmoji]
|
||||||
bot: bool
|
bot: bool
|
||||||
group: bool
|
group: bool
|
||||||
discoverable: Optional[bool]
|
discoverable: Optional[bool]
|
||||||
|
@ -154,10 +155,10 @@ class Poll:
|
||||||
multiple: bool
|
multiple: bool
|
||||||
votes_count: int
|
votes_count: int
|
||||||
voters_count: Optional[int]
|
voters_count: Optional[int]
|
||||||
options: List[PollOption]
|
options: t.List[PollOption]
|
||||||
emojis: List[CustomEmoji]
|
emojis: t.List[CustomEmoji]
|
||||||
voted: Optional[bool]
|
voted: Optional[bool]
|
||||||
own_votes: Optional[List[int]]
|
own_votes: Optional[t.List[int]]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
@ -207,11 +208,11 @@ class Filter:
|
||||||
"""
|
"""
|
||||||
id: str
|
id: str
|
||||||
title: str
|
title: str
|
||||||
context: List[str]
|
context: t.List[str]
|
||||||
expires_at: Optional[datetime]
|
expires_at: Optional[datetime]
|
||||||
filter_action: str
|
filter_action: str
|
||||||
keywords: List[FilterKeyword]
|
keywords: t.List[FilterKeyword]
|
||||||
statuses: List[FilterStatus]
|
statuses: t.List[FilterStatus]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
@ -220,7 +221,7 @@ class FilterResult:
|
||||||
https://docs.joinmastodon.org/entities/FilterResult/
|
https://docs.joinmastodon.org/entities/FilterResult/
|
||||||
"""
|
"""
|
||||||
filter: Filter
|
filter: Filter
|
||||||
keyword_matches: Optional[List[str]]
|
keyword_matches: Optional[t.List[str]]
|
||||||
status_matches: Optional[str]
|
status_matches: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
|
@ -237,11 +238,11 @@ class Status:
|
||||||
visibility: str
|
visibility: str
|
||||||
sensitive: bool
|
sensitive: bool
|
||||||
spoiler_text: str
|
spoiler_text: str
|
||||||
media_attachments: List[MediaAttachment]
|
media_attachments: t.List[MediaAttachment]
|
||||||
application: Optional[Application]
|
application: Optional[Application]
|
||||||
mentions: List[StatusMention]
|
mentions: t.List[StatusMention]
|
||||||
tags: List[StatusTag]
|
tags: t.List[StatusTag]
|
||||||
emojis: List[CustomEmoji]
|
emojis: t.List[CustomEmoji]
|
||||||
reblogs_count: int
|
reblogs_count: int
|
||||||
favourites_count: int
|
favourites_count: int
|
||||||
replies_count: int
|
replies_count: int
|
||||||
|
@ -259,7 +260,7 @@ class Status:
|
||||||
muted: Optional[bool]
|
muted: Optional[bool]
|
||||||
bookmarked: Optional[bool]
|
bookmarked: Optional[bool]
|
||||||
pinned: Optional[bool]
|
pinned: Optional[bool]
|
||||||
filtered: Optional[List[FilterResult]]
|
filtered: Optional[t.List[FilterResult]]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def original(self) -> "Status":
|
def original(self) -> "Status":
|
||||||
|
@ -289,8 +290,8 @@ class Report:
|
||||||
comment: str
|
comment: str
|
||||||
forwarded: bool
|
forwarded: bool
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
status_ids: Optional[List[str]]
|
status_ids: Optional[t.List[str]]
|
||||||
rule_ids: Optional[List[str]]
|
rule_ids: Optional[t.List[str]]
|
||||||
target_account: Account
|
target_account: Account
|
||||||
|
|
||||||
|
|
||||||
|
@ -328,7 +329,7 @@ class InstanceConfigurationStatuses:
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class InstanceConfigurationMediaAttachments:
|
class InstanceConfigurationMediaAttachments:
|
||||||
supported_mime_types: List[str]
|
supported_mime_types: t.List[str]
|
||||||
image_size_limit: int
|
image_size_limit: int
|
||||||
image_matrix_limit: int
|
image_matrix_limit: int
|
||||||
video_size_limit: int
|
video_size_limit: int
|
||||||
|
@ -377,13 +378,13 @@ class Instance:
|
||||||
urls: InstanceUrls
|
urls: InstanceUrls
|
||||||
stats: InstanceStats
|
stats: InstanceStats
|
||||||
thumbnail: Optional[str]
|
thumbnail: Optional[str]
|
||||||
languages: List[str]
|
languages: t.List[str]
|
||||||
registrations: bool
|
registrations: bool
|
||||||
approval_required: bool
|
approval_required: bool
|
||||||
invites_enabled: bool
|
invites_enabled: bool
|
||||||
configuration: InstanceConfiguration
|
configuration: InstanceConfiguration
|
||||||
contact_account: Optional[Account]
|
contact_account: Optional[Account]
|
||||||
rules: List[Rule]
|
rules: t.List[Rule]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
@ -397,7 +398,7 @@ class Relationship:
|
||||||
following: bool
|
following: bool
|
||||||
showing_reblogs: bool
|
showing_reblogs: bool
|
||||||
notifying: bool
|
notifying: bool
|
||||||
languages: List[str]
|
languages: t.List[str]
|
||||||
followed_by: bool
|
followed_by: bool
|
||||||
blocking: bool
|
blocking: bool
|
||||||
blocked_by: bool
|
blocked_by: bool
|
||||||
|
@ -428,7 +429,7 @@ class Tag:
|
||||||
"""
|
"""
|
||||||
name: str
|
name: str
|
||||||
url: str
|
url: str
|
||||||
history: List[TagHistory]
|
history: t.List[TagHistory]
|
||||||
following: Optional[bool]
|
following: Optional[bool]
|
||||||
|
|
||||||
|
|
||||||
|
@ -445,6 +446,19 @@ class FeaturedTag:
|
||||||
last_status_at: datetime
|
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
|
# Generic data class instance
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
@ -481,7 +495,7 @@ def from_dict(cls: Type[T], data: Dict) -> T:
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=100)
|
@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)
|
hints = get_type_hints(cls)
|
||||||
return [
|
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]
|
return [from_dict(cls, x) for x in data]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
import click
|
import click
|
||||||
import re
|
import re
|
||||||
import textwrap
|
|
||||||
import shutil
|
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.utils import get_text, html_to_paragraphs
|
||||||
from toot.wcstring import wc_wrap
|
from toot.wcstring import wc_wrap
|
||||||
from typing import Any, Generator, Iterable, List
|
|
||||||
from wcwidth import wcswidth
|
from wcwidth import wcswidth
|
||||||
|
|
||||||
|
|
||||||
|
@ -38,7 +38,7 @@ def instance_to_text(instance: Instance, width: int) -> str:
|
||||||
return "\n".join(instance_lines(instance, width))
|
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"{green(instance.title)}"
|
||||||
yield f"{blue(instance.uri)}"
|
yield f"{blue(instance.uri)}"
|
||||||
yield f"running Mastodon {instance.version}"
|
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))
|
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}"
|
acct = f"@{account.acct}"
|
||||||
since = account.created_at.strftime("%Y-%m-%d")
|
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']}")
|
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"]
|
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)
|
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 = [[len(cell) for cell in row] for row in data + [headers]]
|
||||||
widths = [max(width) for width in zip(*widths)]
|
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))
|
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()
|
width = get_width()
|
||||||
status_id = status.id
|
status_id = status.id
|
||||||
in_reply_to_id = status.in_reply_to_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}"
|
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
|
first = True
|
||||||
for paragraph in html_to_paragraphs(html):
|
for paragraph in html_to_paragraphs(html):
|
||||||
if not first:
|
if not first:
|
||||||
|
@ -233,7 +233,7 @@ def html_lines(html: str, width: int) -> Generator[str, None, None]:
|
||||||
first = False
|
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):
|
for idx, option in enumerate(poll.options):
|
||||||
perc = (round(100 * option.votes_count / poll.votes_count)
|
perc = (round(100 * option.votes_count / poll.votes_count)
|
||||||
if poll.votes_count and option.votes_count is not None else 0)
|
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
|
yield poll_footer
|
||||||
|
|
||||||
|
|
||||||
def print_timeline(items: Iterable[Status]):
|
def print_timeline(items: t.Iterable[Status]):
|
||||||
print_divider()
|
print_divider()
|
||||||
for item in items:
|
for item in items:
|
||||||
print_status(item)
|
print_status(item)
|
||||||
|
@ -272,7 +272,7 @@ def print_notification(notification: Notification):
|
||||||
print_status(notification.status)
|
print_status(notification.status)
|
||||||
|
|
||||||
|
|
||||||
def print_notifications(notifications: List[Notification]):
|
def print_notifications(notifications: t.List[Notification]):
|
||||||
for notification in notifications:
|
for notification in notifications:
|
||||||
if notification.type not in ['pleroma:emoji_reaction']:
|
if notification.type not in ['pleroma:emoji_reaction']:
|
||||||
print_divider()
|
print_divider()
|
||||||
|
@ -316,25 +316,25 @@ def format_account_name(account: Account) -> str:
|
||||||
|
|
||||||
# Shorthand functions for coloring output
|
# Shorthand functions for coloring output
|
||||||
|
|
||||||
def blue(text: Any) -> str:
|
def blue(text: t.Any) -> str:
|
||||||
return click.style(text, fg="blue")
|
return click.style(text, fg="blue")
|
||||||
|
|
||||||
|
|
||||||
def bold(text: Any) -> str:
|
def bold(text: t.Any) -> str:
|
||||||
return click.style(text, bold=True)
|
return click.style(text, bold=True)
|
||||||
|
|
||||||
|
|
||||||
def cyan(text: Any) -> str:
|
def cyan(text: t.Any) -> str:
|
||||||
return click.style(text, fg="cyan")
|
return click.style(text, fg="cyan")
|
||||||
|
|
||||||
|
|
||||||
def dim(text: Any) -> str:
|
def dim(text: t.Any) -> str:
|
||||||
return click.style(text, dim=True)
|
return click.style(text, dim=True)
|
||||||
|
|
||||||
|
|
||||||
def green(text: Any) -> str:
|
def green(text: t.Any) -> str:
|
||||||
return click.style(text, fg="green")
|
return click.style(text, fg="green")
|
||||||
|
|
||||||
|
|
||||||
def yellow(text: Any) -> str:
|
def yellow(text: t.Any) -> str:
|
||||||
return click.style(text, fg="yellow")
|
return click.style(text, fg="yellow")
|
||||||
|
|
|
@ -35,7 +35,7 @@ class TuiOptions(NamedTuple):
|
||||||
media_viewer: Optional[str]
|
media_viewer: Optional[str]
|
||||||
always_show_sensitive: bool
|
always_show_sensitive: bool
|
||||||
relative_datetimes: bool
|
relative_datetimes: bool
|
||||||
default_visibility: Optional[bool]
|
default_visibility: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
class Header(urwid.WidgetWrap):
|
class Header(urwid.WidgetWrap):
|
||||||
|
|
|
@ -53,7 +53,7 @@ class Status:
|
||||||
self.id = self.data["id"]
|
self.id = self.data["id"]
|
||||||
self.account = self._get_account()
|
self.account = self._get_account()
|
||||||
self.created_at = parse_datetime(data["created_at"])
|
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"])
|
self.edited_at = parse_datetime(data["edited_at"])
|
||||||
else:
|
else:
|
||||||
self.edited_at = None
|
self.edited_at = None
|
||||||
|
|
|
@ -60,7 +60,10 @@ def html_to_widgets(html, recovery_attempt=False) -> List[urwid.Widget]:
|
||||||
|
|
||||||
|
|
||||||
def url_to_widget(url: str):
|
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)
|
return TextEmbed(widget)
|
||||||
|
|
||||||
|
|
||||||
|
@ -98,10 +101,16 @@ def text_to_widget(attr, markup) -> urwid.Widget:
|
||||||
if match:
|
if match:
|
||||||
label, url = match.groups()
|
label, url = match.groups()
|
||||||
anchor_attr = get_best_anchor_attr(attr_list)
|
anchor_attr = get_best_anchor_attr(attr_list)
|
||||||
markup_list.append((
|
try:
|
||||||
len(label),
|
markup_list.append((
|
||||||
urwid.Filler(Hyperlink(url, anchor_attr, label)),
|
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:
|
else:
|
||||||
markup_list.append(run)
|
markup_list.append(run)
|
||||||
else:
|
else:
|
||||||
|
|
|
@ -1,26 +1,22 @@
|
||||||
|
import click
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import socket
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import unicodedata
|
import unicodedata
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, Generator, List, Optional
|
||||||
|
|
||||||
import click
|
|
||||||
|
|
||||||
from toot.exceptions import ConsoleError
|
|
||||||
from urllib.parse import urlparse, urlencode, quote, unquote
|
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."""
|
"""Convert boolean to string, in the way expected by the API."""
|
||||||
return "true" if b else "false"
|
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"""
|
"""Similar to str_bool, but leave None as None"""
|
||||||
return None if b is None else str_bool(b)
|
return None if b is None else str_bool(b)
|
||||||
|
|
||||||
|
@ -34,7 +30,7 @@ def parse_html(html: str) -> BeautifulSoup:
|
||||||
return BeautifulSoup(html.replace("'", "'"), "html.parser")
|
return BeautifulSoup(html.replace("'", "'"), "html.parser")
|
||||||
|
|
||||||
|
|
||||||
def get_text(html):
|
def get_text(html: str) -> str:
|
||||||
"""Converts html to text, strips all tags."""
|
"""Converts html to text, strips all tags."""
|
||||||
text = parse_html(html).get_text()
|
text = parse_html(html).get_text()
|
||||||
return unicodedata.normalize("NFKC", 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]
|
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.
|
"""Given a Status contents in HTML, converts it into lines of plain text.
|
||||||
|
|
||||||
Returns a generator yielding lines of content.
|
Returns a generator yielding lines of content.
|
||||||
|
@ -73,25 +69,12 @@ def format_content(content):
|
||||||
first = False
|
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"
|
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."""
|
"""Lets user input multiple lines of text, terminated by EOF."""
|
||||||
lines = []
|
lines: List[str] = []
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
lines.append(input())
|
lines.append(input())
|
||||||
|
|
|
@ -4,7 +4,7 @@ import os
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
def parse_datetime(value):
|
def parse_datetime(value: str) -> datetime:
|
||||||
"""Returns an aware datetime in local timezone"""
|
"""Returns an aware datetime in local timezone"""
|
||||||
dttm = datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%f%z")
|
dttm = datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%f%z")
|
||||||
|
|
||||||
|
|
Ładowanie…
Reference in New Issue