Merge branch 'master' into rt-hyperlinks-urwidgets-stub

This commit is contained in:
Daniel Schwarz 2023-09-22 19:07:59 -04:00
commit c74539f147
31 changed files with 1289 additions and 300 deletions

View File

@ -25,7 +25,7 @@ jobs:
pytest
- name: Validate minimum required version
run: |
vermin --target=3.6 --no-tips .
vermin --target=3.7 --no-tips .
- name: Check style
run: |
flake8

View File

@ -3,11 +3,29 @@ Changelog
<!-- Do not edit. This file is automatically generated from changelog.yaml.-->
**0.37.0 (TBA)**
**0.38.1 (2023-07-25)**
* Fix relative datetimes option in TUI
**0.38.0 (2023-07-25)**
* Add `toot muted` and `toot blocked` commands (thanks Florian Obser)
* Add settings file, allows setting common options, defining defaults for
command arguments, and the TUI palette
* TUI: Remap shortcuts so they don't override HJKL used for navigation (thanks
Dan Schwarz)
**0.37.0 (2023-06-28)**
* **BREAKING:** Require Python 3.7+
* Add `timeline --account` option to show the account timeline (thanks Dan
Schwarz)
* Add `toot status` command to show a single status
* TUI: Add personal timeline (thanks Dan Schwarz)
* TUI: Highlight followed accounts in status details (thanks Dan Schwarz)
* TUI: Restructured goto menu (thanks Dan Schwarz)
* TUI: Fix boosting boosted statuses (thanks Dan Schwarz)
* TUI: Add support for list timelines (thanks Dan Schwarz)
**0.36.0 (2023-03-09)**

View File

@ -10,7 +10,7 @@ publish :
test:
pytest tests/*.py -v
flake8
vermin --target=3.6 --no-tips --violations --exclude-regex venv/.* .
vermin --target=3.7 --no-tips --violations --exclude-regex venv/.* .
coverage:
coverage erase

View File

@ -1,9 +1,26 @@
0.38.1:
date: 2023-07-25
changes:
- "Fix relative datetimes option in TUI"
0.38.0:
date: 2023-07-25
changes:
- "Add `toot muted` and `toot blocked` commands (thanks Florian Obser)"
- "Add settings file, allows setting common options, defining defaults for command arguments, and the TUI palette"
- "TUI: Remap shortcuts so they don't override HJKL used for navigation (thanks Dan Schwarz)"
0.37.0:
date: "TBA"
date: 2023-06-28
changes:
- "**BREAKING:** Require Python 3.7+"
- "Add `timeline --account` option to show the account timeline (thanks Dan Schwarz)"
- "Add `toot status` command to show a single status"
- "TUI: Add personal timeline (thanks Dan Schwarz)"
- "TUI: Highlight followed accounts in status details (thanks Dan Schwarz)"
- "TUI: Restructured goto menu (thanks Dan Schwarz)"
- "TUI: Fix boosting boosted statuses (thanks Dan Schwarz)"
- "TUI: Add support for list timelines (thanks Dan Schwarz)"
0.36.0:
date: 2023-03-09

View File

@ -5,6 +5,7 @@
- [Installation](installation.md)
- [Usage](usage.md)
- [Advanced](advanced.md)
- [Settings](settings.md)
- [TUI](tui.md)
- [Contributing](contributing.md)
- [Documentation](documentation.md)

View File

@ -21,10 +21,10 @@ through the specified server.
For example:
.. code-block:: sh
export HTTPS_PROXY="http://1.2.3.4:5678"
toot login --instance mastodon.social
```sh
export HTTPS_PROXY="http://1.2.3.4:5678"
toot login --instance mastodon.social
```
**NB:** This feature is provided by
[requests](http://docs.python-requests.org/en/master/user/advanced/#proxies>)

View File

@ -3,11 +3,29 @@ Changelog
<!-- Do not edit. This file is automatically generated from changelog.yaml.-->
**0.37.0 (TBA)**
**0.38.1 (2023-07-25)**
* Fix relative datetimes option in TUI
**0.38.0 (2023-07-25)**
* Add `toot muted` and `toot blocked` commands (thanks Florian Obser)
* Add settings file, allows setting common options, defining defaults for
command arguments, and the TUI palette
* TUI: Remap shortcuts so they don't override HJKL used for navigation (thanks
Dan Schwarz)
**0.37.0 (2023-06-28)**
* **BREAKING:** Require Python 3.7+
* Add `timeline --account` option to show the account timeline (thanks Dan
Schwarz)
* Add `toot status` command to show a single status
* TUI: Add personal timeline (thanks Dan Schwarz)
* TUI: Highlight followed accounts in status details (thanks Dan Schwarz)
* TUI: Restructured goto menu (thanks Dan Schwarz)
* TUI: Fix boosting boosted statuses (thanks Dan Schwarz)
* TUI: Add support for list timelines (thanks Dan Schwarz)
**0.36.0 (2023-03-09)**

111
docs/settings.md Normal file
View File

@ -0,0 +1,111 @@
# Settings
Toot can be configured via a [TOML](https://toml.io/en/) settings file.
> Introduced in toot 0.37.0
> **Warning:** Settings are experimental and things may change without warning.
Toot will look for the settings file at:
* `~/.config/toot/settings.toml` (Linux & co.)
* `%APPDATA%\toot\settings.toml` (Windows)
Toot will respect the `XDG_CONFIG_HOME` environement variable if it's set and
look for the settings file in `$XDG_CONFIG_HOME/toot` instead of
`~/.config/toot`.
## Common options
The `[common]` section includes common options which are applied to all commands.
```toml
[common]
# Whether to use ANSI color in output
color = true
# Enable debug logging, shows HTTP requests
debug = true
# Redirect debug log to the given file
debug_file = "/tmp/toot.log"
# Log request and response bodies in the debug log
verbose = false
# Do not write to output
quiet = false
```
## Overriding command defaults
Defaults for command arguments can be override by specifying a `[commands.<name>]` section.
For example, to override `toot post`.
```toml
[commands.post]
editor = "vim"
sensitive = true
visibility = "unlisted"
scheduled_in = "30 minutes"
```
## TUI color palette
TUI uses Urwid which provides several color modes. See
[Urwid documentation](https://urwid.org/manual/displayattributes.html)
for more details.
By default, TUI operates in 16-color mode which can be changed by setting the
`color` setting in the `[tui]` section to one of the following values:
* `1` (monochrome)
* `16` (default)
* `88`
* `256`
* `16777216` (24 bit)
TUI defines a list of colors which can be customized, currently they can be seen
[in the source code](https://github.com/ihabunek/toot/blob/master/toot/tui/constants.py). They can be overriden in the `[tui.palette]` section.
Each color is defined as a list of upto 5 values:
* foreground color (16 color mode)
* background color (16 color mode)
* monochrome color (monochrome mode)
* foreground color (high-color mode)
* background color (high-color mode)
Any colors which are not used by your desired color mode can be skipped or set
to an empty string.
For example, to change the button colors in 16 color mode:
```toml
[tui.palette]
button = ["dark red,bold", ""]
button_focused = ["light gray", "green"]
```
In monochrome mode:
```toml
[tui]
colors = 1
[tui.palette]
button = ["", "", "bold"]
button_focused = ["", "", "italics"]
```
In 256 color mode:
```toml
[tui]
colors = 256
[tui.palette]
button = ["", "", "", "#aaa", "#bbb"]
button_focused = ["", "", "", "#aaa", "#bbb"]
```

View File

@ -12,7 +12,7 @@ and blocking accounts and other actions.
setup(
name='toot',
version='0.36.0',
version='0.38.1',
description='Mastodon CLI client',
long_description=long_description.strip(),
author='Ivan Habunek',
@ -39,6 +39,7 @@ setup(
"wcwidth>=0.1.7",
"urwid>=2.0.0,<3.0",
"urwidgets>=0.1,<0.2",
"tomlkit>=0.10.0,<1.0"
],
entry_points={
'console_scripts': [

View File

@ -8,7 +8,7 @@ To enable integration tests, export the following environment variables to match
your test server and database:
```
export TOOT_TEST_HOSTNAME="localhost:3000"
export TOOT_TEST_BASE_URL="localhost:3000"
export TOOT_TEST_DATABASE_DSN="dbname=mastodon_development"
```
"""
@ -26,6 +26,11 @@ from toot.exceptions import ApiError, ConsoleError
from toot.output import print_out
def pytest_configure(config):
import toot.settings
toot.settings.DISABLE_SETTINGS = True
# Mastodon database name, used to confirm user registration without having to click the link
DATABASE_DSN = os.getenv("TOOT_TEST_DATABASE_DSN")

View File

@ -1,3 +1,5 @@
import re
from uuid import uuid4
import pytest
from toot import api
@ -80,3 +82,35 @@ def test_tags(run, base_url):
out = run("tags_followed")
assert out == f"* #bar\t{base_url}/tags/bar"
def test_status(app, user, run):
uuid = str(uuid4())
response = api.post_status(app, user, uuid)
out = run("status", response["id"])
assert uuid in out
assert user.username in out
assert response["id"] in out
def test_thread(app, user, run):
uuid = str(uuid4())
s1 = api.post_status(app, user, uuid + "1")
s2 = api.post_status(app, user, uuid + "2", in_reply_to_id=s1["id"])
s3 = api.post_status(app, user, uuid + "3", in_reply_to_id=s2["id"])
for status in [s1, s2, s3]:
out = run("thread", status["id"])
bits = re.split(r"─+", out)
bits = [b for b in bits if b]
assert len(bits) == 3
assert s1["id"] in bits[0]
assert s2["id"] in bits[1]
assert s3["id"] in bits[2]
assert f"{uuid}1" in bits[0]
assert f"{uuid}2" in bits[1]
assert f"{uuid}3" in bits[2]

View File

@ -161,6 +161,7 @@ def test_timeline_with_re(mock_get, monkeypatch, capsys):
'acct': 'fz'
},
'reblog': {
'created_at': '2017-04-12T15:53:18.174Z',
'account': {
'display_name': 'Johnny Cash',
'acct': 'jc'
@ -179,8 +180,8 @@ def test_timeline_with_re(mock_get, monkeypatch, capsys):
out, err = capsys.readouterr()
lines = uncolorize(out).split("\n")
assert "Frank Zappa" in lines[1]
assert "@fz" in lines[1]
assert "Johnny Cash" in lines[1]
assert "@jc" in lines[1]
assert "2017-04-12 15:53 UTC" in lines[1]
assert (
@ -188,7 +189,7 @@ def test_timeline_with_re(mock_get, monkeypatch, capsys):
"exact mathematical design, but\nwhat's missing is the eyebrows." in out)
assert "111111111111111111" in lines[-3]
assert "Reblogged @jc" in lines[-3]
assert "@fz boosted" in lines[-3]
assert err == ""

View File

@ -1,6 +1,10 @@
import os
import sys
from os.path import join, expanduser
from collections import namedtuple
__version__ = '0.36.0'
__version__ = '0.38.1'
App = namedtuple('App', ['instance', 'base_url', 'client_id', 'client_secret'])
User = namedtuple('User', ['instance', 'username', 'access_token'])
@ -9,3 +13,22 @@ DEFAULT_INSTANCE = 'https://mastodon.social'
CLIENT_NAME = 'toot - a Mastodon CLI client'
CLIENT_WEBSITE = 'https://github.com/ihabunek/toot'
TOOT_CONFIG_DIR_NAME = "toot"
def get_config_dir():
"""Returns the path to toot config directory"""
# On Windows, store the config in roaming appdata
if sys.platform == "win32" and "APPDATA" in os.environ:
return join(os.getenv("APPDATA"), TOOT_CONFIG_DIR_NAME)
# Respect XDG_CONFIG_HOME env variable if set
# https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
if "XDG_CONFIG_HOME" in os.environ:
config_home = expanduser(os.environ["XDG_CONFIG_HOME"])
return join(config_home, TOOT_CONFIG_DIR_NAME)
# Default to ~/.config/toot/
return join(expanduser("~"), ".config", TOOT_CONFIG_DIR_NAME)

3
toot/__main__.py Normal file
View File

@ -0,0 +1,3 @@
from .console import main
main()

View File

@ -521,6 +521,10 @@ def unmute(app, user, account):
return _account_action(app, user, account, 'unmute')
def muted(app, user):
return _get_response_list(app, user, "/api/v1/mutes")
def block(app, user, account):
return _account_action(app, user, account, 'block')
@ -529,6 +533,10 @@ def unblock(app, user, account):
return _account_action(app, user, account, 'unblock')
def blocked(app, user):
return _get_response_list(app, user, "/api/v1/blocks")
def verify_credentials(app, user):
return http.get(app, user, '/api/v1/accounts/verify_credentials').json()

View File

@ -6,12 +6,13 @@ from datetime import datetime, timedelta, timezone
from time import sleep, time
from toot import api, config, __version__
from toot.auth import login_interactive, login_browser_interactive, create_app_interactive
from toot.entities import Instance, Notification, Status, from_dict
from toot.exceptions import ApiError, ConsoleError
from toot.output import (print_lists, print_out, print_instance, print_account, print_acct_list,
print_search_results, print_timeline, print_notifications, print_tag_list,
print_search_results, print_status, print_timeline, print_notifications, print_tag_list,
print_list_accounts, print_user_list)
from toot.tui.utils import parse_datetime
from toot.utils import args_get_instance, delete_tmp_status_file, editor_input, multiline_input, EOF_KEY
from toot.utils.datetime import parse_datetime
def get_timeline_generator(app, user, args):
@ -56,7 +57,8 @@ def timeline(app, user, args, generator=None):
if args.reverse:
items = reversed(items)
print_timeline(items)
statuses = [from_dict(Status, item) for item in items]
print_timeline(statuses)
if args.once or not sys.stdout.isatty():
break
@ -66,6 +68,12 @@ def timeline(app, user, args, generator=None):
break
def status(app, user, args):
status = api.single_status(app, user, args.status_id)
status = from_dict(Status, status)
print_status(status)
def thread(app, user, args):
toot = api.single_status(app, user, args.status_id)
context = api.context(app, user, args.status_id)
@ -78,7 +86,8 @@ def thread(app, user, args):
for item in context['descendants']:
thread.append(item)
print_timeline(thread)
statuses = [from_dict(Status, s) for s in thread]
print_timeline(statuses)
def post(app, user, args):
@ -484,6 +493,11 @@ def unmute(app, user, args):
print_out("<green>✓ {} is no longer muted</green>".format(args.account))
def muted(app, user, args):
response = api.muted(app, user)
print_acct_list(response)
def block(app, user, args):
account = api.find_account(app, user, args.account)
api.block(app, user, account['id'])
@ -496,6 +510,11 @@ def unblock(app, user, args):
print_out("<green>✓ {} is no longer blocked</green>".format(args.account))
def blocked(app, user, args):
response = api.blocked(app, user)
print_acct_list(response)
def whoami(app, user, args):
account = api.verify_credentials(app, user)
print_account(account)
@ -515,6 +534,7 @@ def instance(app, user, args):
try:
instance = api.get_instance(base_url)
instance = from_dict(Instance, instance)
print_instance(instance)
except ApiError:
raise ConsoleError(
@ -542,6 +562,7 @@ def notifications(app, user, args):
if args.reverse:
notifications = reversed(notifications)
notifications = [from_dict(Notification, n) for n in notifications]
print_notifications(notifications)

View File

@ -1,44 +1,22 @@
import json
import os
import sys
from functools import wraps
from os.path import dirname, join, expanduser
from os.path import dirname, join
from toot import User, App
from toot import User, App, get_config_dir
from toot.exceptions import ConsoleError
from toot.output import print_out
TOOT_CONFIG_DIR_NAME = "toot"
TOOT_CONFIG_FILE_NAME = "config.json"
def get_config_dir():
"""Returns the path to toot config directory"""
# On Windows, store the config in roaming appdata
if sys.platform == "win32" and "APPDATA" in os.environ:
return join(os.getenv("APPDATA"), TOOT_CONFIG_DIR_NAME)
# Respect XDG_CONFIG_HOME env variable if set
# https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
if "XDG_CONFIG_HOME" in os.environ:
config_home = expanduser(os.environ["XDG_CONFIG_HOME"])
return join(config_home, TOOT_CONFIG_DIR_NAME)
# Default to ~/.config/toot/
return join(expanduser("~"), ".config", TOOT_CONFIG_DIR_NAME)
def get_config_file_path():
"""Returns the path to toot config file."""
return join(get_config_dir(), TOOT_CONFIG_FILE_NAME)
CONFIG_FILE = get_config_file_path()
def user_id(user):
return "{}@{}".format(user.username, user.instance)
@ -63,15 +41,18 @@ def make_config(path):
def load_config():
if not os.path.exists(CONFIG_FILE):
make_config(CONFIG_FILE)
path = get_config_file_path()
with open(CONFIG_FILE) as f:
if not os.path.exists(path):
make_config(path)
with open(path) as f:
return json.load(f)
def save_config(config):
with open(CONFIG_FILE, 'w') as f:
path = get_config_file_path()
with open(path, "w") as f:
return json.dump(config, f, indent=True, sort_keys=True)

View File

@ -7,9 +7,10 @@ import sys
from argparse import ArgumentParser, FileType, ArgumentTypeError, Action
from collections import namedtuple
from itertools import chain
from toot import config, commands, CLIENT_NAME, CLIENT_WEBSITE, __version__
from toot import config, commands, CLIENT_NAME, CLIENT_WEBSITE, __version__, settings
from toot.exceptions import ApiError, ConsoleError
from toot.output import print_out, print_err
from toot.settings import get_setting
VISIBILITY_CHOICES = ["public", "unlisted", "private", "direct"]
VISIBILITY_CHOICES_STR = ", ".join(f"'{v}'" for v in VISIBILITY_CHOICES)
@ -459,6 +460,16 @@ READ_COMMANDS = [
],
require_auth=True,
),
Command(
name="status",
description="Show a single status",
arguments=[
(["status_id"], {
"help": "ID of the status to show.",
}),
],
require_auth=True,
),
Command(
name="timeline",
description="Show recent items in a timeline (home by default)",
@ -693,6 +704,12 @@ ACCOUNTS_COMMANDS = [
],
require_auth=True,
),
Command(
name="muted",
description="List muted accounts",
arguments=[],
require_auth=True,
),
Command(
name="block",
description="Block an account",
@ -709,6 +726,12 @@ ACCOUNTS_COMMANDS = [
],
require_auth=True,
),
Command(
name="blocked",
description="List blocked accounts",
arguments=[],
require_auth=True,
),
]
TAG_COMMANDS = [
@ -872,12 +895,24 @@ def get_argument_parser(name, command):
if command.require_auth:
combined_args += common_auth_args
defaults = get_setting(f"commands.{name}", dict, {})
for args, kwargs in combined_args:
# Set default value from settings if exists
default = get_default_value(defaults, args)
if default is not None:
kwargs["default"] = default
parser.add_argument(*args, **kwargs)
return parser
def get_default_value(defaults, args):
# Hacky way to determine command name from argparse args
name = args[-1].lstrip("-").replace("-", "_")
return defaults.get(name)
def run_command(app, user, name, args):
command = next((c for c in COMMANDS if c.name == name), None)
@ -909,9 +944,8 @@ def run_command(app, user, name, args):
def main():
# Enable debug logging if --debug is in args
if "--debug" in sys.argv:
filename = os.getenv("TOOT_LOG_FILE")
if settings.get_debug():
filename = settings.get_debug_file()
logging.basicConfig(level=logging.DEBUG, filename=filename)
logging.getLogger("urllib3").setLevel(logging.INFO)

416
toot/entities.py Normal file
View File

@ -0,0 +1,416 @@
"""
Dataclasses which represent entities returned by the Mastodon API.
"""
import dataclasses
from dataclasses import dataclass, is_dataclass
from datetime import date, datetime
from typing import Dict, List, Optional, Type, TypeVar, Union
from typing import get_type_hints
from toot.typing_compat import get_args, get_origin
from toot.utils import get_text
@dataclass
class AccountField:
"""
https://docs.joinmastodon.org/entities/Account/#Field
"""
name: str
value: str
verified_at: Optional[datetime]
@dataclass
class CustomEmoji:
"""
https://docs.joinmastodon.org/entities/CustomEmoji/
"""
shortcode: str
url: str
static_url: str
visible_in_picker: bool
category: str
@dataclass
class Account:
"""
https://docs.joinmastodon.org/entities/Account/
"""
id: str
username: str
acct: str
url: str
display_name: str
note: str
avatar: str
avatar_static: str
header: str
header_static: str
locked: bool
fields: List[AccountField]
emojis: List[CustomEmoji]
bot: bool
group: bool
discoverable: Optional[bool]
noindex: Optional[bool]
moved: Optional["Account"]
suspended: Optional[bool]
limited: Optional[bool]
created_at: datetime
last_status_at: Optional[date]
statuses_count: int
followers_count: int
following_count: int
@property
def note_plaintext(self) -> str:
return get_text(self.note)
@dataclass
class Application:
"""
https://docs.joinmastodon.org/entities/Status/#application
"""
name: str
website: Optional[str]
@dataclass
class MediaAttachment:
"""
https://docs.joinmastodon.org/entities/MediaAttachment/
"""
id: str
type: str
url: str
preview_url: str
remote_url: Optional[str]
meta: dict
description: str
blurhash: str
@dataclass
class StatusMention:
"""
https://docs.joinmastodon.org/entities/Status/#Mention
"""
id: str
username: str
url: str
acct: str
@dataclass
class StatusTag:
"""
https://docs.joinmastodon.org/entities/Status/#Tag
"""
name: str
url: str
@dataclass
class PollOption:
"""
https://docs.joinmastodon.org/entities/Poll/#Option
"""
title: str
votes_count: Optional[int]
@dataclass
class Poll:
"""
https://docs.joinmastodon.org/entities/Poll/
"""
id: str
expires_at: Optional[datetime]
expired: bool
multiple: bool
votes_count: int
voters_count: Optional[int]
options: List[PollOption]
emojis: List[CustomEmoji]
voted: Optional[bool]
own_votes: Optional[List[int]]
@dataclass
class PreviewCard:
"""
https://docs.joinmastodon.org/entities/PreviewCard/
"""
url: str
title: str
description: str
type: str
author_name: str
author_url: str
provider_name: str
provider_url: str
html: str
width: int
height: int
image: Optional[str]
embed_url: str
blurhash: Optional[str]
@dataclass
class FilterKeyword:
"""
https://docs.joinmastodon.org/entities/FilterKeyword/
"""
id: str
keyword: str
whole_word: str
@dataclass
class FilterStatus:
"""
https://docs.joinmastodon.org/entities/FilterStatus/
"""
id: str
status_id: str
@dataclass
class Filter:
"""
https://docs.joinmastodon.org/entities/Filter/
"""
id: str
title: str
context: List[str]
expires_at: Optional[datetime]
filter_action: str
keywords: List[FilterKeyword]
statuses: List[FilterStatus]
@dataclass
class FilterResult:
"""
https://docs.joinmastodon.org/entities/FilterResult/
"""
filter: Filter
keyword_matches: Optional[List[str]]
status_matches: Optional[str]
@dataclass
class Status:
"""
https://docs.joinmastodon.org/entities/Status/
"""
id: str
uri: str
created_at: datetime
account: Account
content: str
visibility: str
sensitive: bool
spoiler_text: str
media_attachments: List[MediaAttachment]
application: Optional[Application]
mentions: List[StatusMention]
tags: List[StatusTag]
emojis: List[CustomEmoji]
reblogs_count: int
favourites_count: int
replies_count: int
url: Optional[str]
in_reply_to_id: Optional[str]
in_reply_to_account_id: Optional[str]
reblog: Optional["Status"]
poll: Optional[Poll]
card: Optional[PreviewCard]
language: Optional[str]
text: Optional[str]
edited_at: Optional[datetime]
favourited: Optional[bool]
reblogged: Optional[bool]
muted: Optional[bool]
bookmarked: Optional[bool]
pinned: Optional[bool]
filtered: Optional[List[FilterResult]]
@property
def original(self) -> "Status":
return self.reblog or self
@dataclass
class Report:
"""
https://docs.joinmastodon.org/entities/Report/
"""
id: str
action_taken: bool
action_taken_at: Optional[datetime]
category: str
comment: str
forwarded: bool
created_at: datetime
status_ids: Optional[List[str]]
rule_ids: Optional[List[str]]
target_account: Account
@dataclass
class Notification:
"""
https://docs.joinmastodon.org/entities/Notification/
"""
id: str
type: str
created_at: datetime
account: Account
status: Optional[Status]
report: Optional[Report]
@dataclass
class InstanceUrls:
streaming_api: str
@dataclass
class InstanceStats:
user_count: int
status_count: int
domain_count: int
@dataclass
class InstanceConfigurationStatuses:
max_characters: int
max_media_attachments: int
characters_reserved_per_url: int
@dataclass
class InstanceConfigurationMediaAttachments:
supported_mime_types: List[str]
image_size_limit: int
image_matrix_limit: int
video_size_limit: int
video_frame_rate_limit: int
video_matrix_limit: int
@dataclass
class InstanceConfigurationPolls:
max_options: int
max_characters_per_option: int
min_expiration: int
max_expiration: int
@dataclass
class InstanceConfiguration:
"""
https://docs.joinmastodon.org/entities/V1_Instance/#configuration
"""
statuses: InstanceConfigurationStatuses
media_attachments: InstanceConfigurationMediaAttachments
polls: InstanceConfigurationPolls
@dataclass
class Rule:
"""
https://docs.joinmastodon.org/entities/Rule/
"""
id: str
text: str
@dataclass
class Instance:
"""
https://docs.joinmastodon.org/entities/V1_Instance/
"""
uri: str
title: str
short_description: str
description: str
email: str
version: str
urls: InstanceUrls
stats: InstanceStats
thumbnail: Optional[str]
languages: List[str]
registrations: bool
approval_required: bool
invites_enabled: bool
configuration: InstanceConfiguration
contact_account: Optional[Account]
rules: List[Rule]
# Generic data class instance
T = TypeVar("T")
def from_dict(cls: Type[T], data: Dict) -> T:
"""Convert a nested dict into an instance of `cls`."""
def _fields():
hints = get_type_hints(cls)
for field in dataclasses.fields(cls):
field_type = _prune_optional(hints[field.name])
default_value = _get_default_value(field)
value = data.get(field.name, default_value)
yield field.name, _convert(field_type, value)
return cls(**dict(_fields()))
def _get_default_value(field):
if field.default is not dataclasses.MISSING:
return field.default
if field.default_factory is not dataclasses.MISSING:
return field.default_factory()
return None
def _convert(field_type, value):
if value is None:
return None
if field_type in [str, int, bool, dict]:
return value
if field_type == datetime:
return datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%f%z")
if field_type == date:
return date.fromisoformat(value)
if get_origin(field_type) == list:
(inner_type,) = get_args(field_type)
return [_convert(inner_type, x) for x in value]
if is_dataclass(field_type):
return from_dict(field_type, value)
raise ValueError(f"Not implemented for type '{field_type}'")
def _prune_optional(field_type):
"""For `Optional[<type>]` returns the encapsulated `<type>`."""
if get_origin(field_type) == Union:
args = get_args(field_type)
if len(args) == 2 and args[1] == type(None): # noqa
return args[0]
return field_type

View File

@ -3,12 +3,13 @@ import re
import sys
import textwrap
from typing import List
from wcwidth import wcswidth
from toot.tui.utils import parse_datetime
from functools import lru_cache
from toot import settings
from toot.entities import Instance, Notification, Poll, Status
from toot.utils import get_text, parse_html
from toot.wcstring import wc_wrap
from typing import List
from wcwidth import wcswidth
STYLES = {
@ -100,6 +101,7 @@ def strip_tags(message):
return re.sub(STYLE_TAG_PATTERN, "", message)
@lru_cache(maxsize=None)
def use_ansi_color():
"""Returns True if ANSI color codes should be used."""
@ -116,45 +118,44 @@ def use_ansi_color():
if "--no-color" in sys.argv:
return False
# Check in settings
color = settings.get_setting("common.color", bool)
if color is not None:
return color
# Use color by default
return True
USE_ANSI_COLOR = use_ansi_color()
QUIET = "--quiet" in sys.argv
def print_out(*args, **kwargs):
if not QUIET:
args = [colorize(a) if USE_ANSI_COLOR else strip_tags(a) for a in args]
if not settings.get_quiet():
args = [colorize(a) if use_ansi_color() else strip_tags(a) for a in args]
print(*args, **kwargs)
def print_err(*args, **kwargs):
args = [f"<red>{a}</red>" for a in args]
args = [colorize(a) if USE_ANSI_COLOR else strip_tags(a) for a in args]
args = [colorize(a) if use_ansi_color() else strip_tags(a) for a in args]
print(*args, file=sys.stderr, **kwargs)
def print_instance(instance):
print_out(f"<green>{instance['title']}</green>")
print_out(f"<blue>{instance['uri']}</blue>")
print_out(f"running Mastodon {instance['version']}")
def print_instance(instance: Instance):
print_out(f"<green>{instance.title}</green>")
print_out(f"<blue>{instance.uri}</blue>")
print_out(f"running Mastodon {instance.version}")
print_out()
description = instance.get("description")
if description:
for paragraph in re.split(r"[\r\n]+", description.strip()):
if instance.description:
for paragraph in re.split(r"[\r\n]+", instance.description.strip()):
paragraph = get_text(paragraph)
print_out(textwrap.fill(paragraph, width=80))
print_out()
rules = instance.get("rules")
if rules:
if instance.rules:
print_out("Rules:")
for ordinal, rule in enumerate(rules):
for ordinal, rule in enumerate(instance.rules):
ordinal = f"{ordinal + 1}."
lines = textwrap.wrap(rule["text"], 80 - len(ordinal))
lines = textwrap.wrap(rule.text, 80 - len(ordinal))
first = True
for line in lines:
if first:
@ -162,6 +163,11 @@ def print_instance(instance):
first = False
else:
print_out(f"{' ' * len(ordinal)} {line}")
print_out()
contact = instance.contact_account
if contact:
print_out(f"Contact: {contact.display_name} @{contact.acct}")
def print_account(account):
@ -269,20 +275,18 @@ def print_search_results(results):
print_out("<yellow>Nothing found</yellow>")
def print_status(status, width):
reblog = status['reblog']
content = reblog['content'] if reblog else status['content']
media_attachments = reblog['media_attachments'] if reblog else status['media_attachments']
in_reply_to = status['in_reply_to_id']
poll = reblog.get('poll') if reblog else status.get('poll')
def print_status(status: Status, width: int = 80):
status_id = status.id
in_reply_to_id = status.in_reply_to_id
reblogged_by = status.account if status.reblog else None
time = parse_datetime(status['created_at'])
time = time.strftime('%Y-%m-%d %H:%M %Z')
status = status.original
username = "@" + status['account']['acct']
time = status.created_at.strftime('%Y-%m-%d %H:%M %Z')
username = "@" + status.account.acct
spacing = width - wcswidth(username) - wcswidth(time) - 2
display_name = status['account']['display_name']
display_name = status.account.display_name
if display_name:
spacing -= wcswidth(display_name) + 1
@ -294,23 +298,24 @@ def print_status(status, width):
)
print_out("")
print_html(content, width)
print_html(status.content, width)
if media_attachments:
if status.media_attachments:
print_out("\nMedia:")
for attachment in media_attachments:
url = attachment["url"]
for attachment in status.media_attachments:
url = attachment.url
for line in wc_wrap(url, width):
print_out(line)
if poll:
print_poll(poll)
if status.poll:
print_poll(status.poll)
print_out()
print_out(
f"ID <yellow>{status['id']}</yellow> ",
f"↲ In reply to <yellow>{in_reply_to}</yellow> " if in_reply_to else "",
f"Reblogged <blue>@{reblog['account']['acct']}</blue> " if reblog else "",
f"ID <yellow>{status_id}</yellow> ",
f"↲ In reply to <yellow>{in_reply_to_id}</yellow> " if in_reply_to_id else "",
f"<blue>@{reblogged_by.acct}</blue> boosted " if reblogged_by else "",
)
@ -325,33 +330,33 @@ def print_html(text, width=80):
first = False
def print_poll(poll):
def print_poll(poll: Poll):
print_out()
for idx, option in enumerate(poll["options"]):
perc = (round(100 * option["votes_count"] / poll["votes_count"])
if poll["votes_count"] else 0)
for idx, option in enumerate(poll.options):
perc = (round(100 * option.votes_count / poll.votes_count)
if poll.votes_count and option.votes_count is not None else 0)
if poll["voted"] and poll["own_votes"] and idx in poll["own_votes"]:
if poll.voted and poll.own_votes and idx in poll.own_votes:
voted_for = " <yellow>✓</yellow>"
else:
voted_for = ""
print_out(f'{option["title"]} - {perc}% {voted_for}')
print_out(f'{option.title} - {perc}% {voted_for}')
poll_footer = f'Poll · {poll["votes_count"]} votes'
poll_footer = f'Poll · {poll.votes_count} votes'
if poll["expired"]:
if poll.expired:
poll_footer += " · Closed"
if poll["expires_at"]:
expires_at = parse_datetime(poll["expires_at"]).strftime("%Y-%m-%d %H:%M")
if poll.expires_at:
expires_at = poll.expires_at.strftime("%Y-%m-%d %H:%M")
poll_footer += f" · Closes on {expires_at}"
print_out()
print_out(poll_footer)
def print_timeline(items, width=100):
def print_timeline(items: List[Status], width=100):
print_out("" * width)
for item in items:
print_status(item, width)
@ -366,20 +371,19 @@ notification_msgs = {
}
def print_notification(notification, width=100):
account = "{display_name} @{acct}".format(**notification["account"])
msg = notification_msgs.get(notification["type"])
def print_notification(notification: Notification, width=100):
account = f"{notification.account.display_name} @{notification.account.acct}"
msg = notification_msgs.get(notification.type)
if msg is None:
return
print_out("" * width)
print_out(msg.format(account=account))
status = notification.get("status")
if status is not None:
print_status(status, width)
if notification.status:
print_status(notification.status, width)
def print_notifications(notifications, width=100):
def print_notifications(notifications: List[Notification], width=100):
for notification in notifications:
print_notification(notification)
print_out("" * width)

87
toot/settings.py Normal file
View File

@ -0,0 +1,87 @@
import os
import sys
from functools import lru_cache
from os.path import exists, join
from tomlkit import parse
from toot import get_config_dir
from typing import Optional, Type, TypeVar
DISABLE_SETTINGS = False
TOOT_SETTINGS_FILE_NAME = "settings.toml"
def get_settings_path():
return join(get_config_dir(), TOOT_SETTINGS_FILE_NAME)
def load_settings() -> dict:
# Used for testing without config file
if DISABLE_SETTINGS:
return {}
path = get_settings_path()
if not exists(path):
return {}
with open(path) as f:
return parse(f.read())
@lru_cache(maxsize=None)
def get_settings():
return load_settings()
T = TypeVar("T")
def get_setting(key: str, type: Type[T], default: Optional[T] = None) -> Optional[T]:
"""
Get a setting value. The key should be a dot-separated string,
e.g. "commands.post.editor" which will correspond to the "editor" setting
inside the `[commands.post]` section.
"""
settings = get_settings()
return _get_setting(settings, key.split("."), type, default)
def _get_setting(dct, keys, type: Type, default=None):
if len(keys) == 0:
if isinstance(dct, type):
return dct
else:
# TODO: warn? cast? both?
return default
key = keys[0]
if isinstance(dct, dict) and key in dct:
return _get_setting(dct[key], keys[1:], type, default)
return default
def get_debug() -> bool:
if "--debug" in sys.argv:
return True
return get_setting("common.debug", bool, False)
def get_debug_file() -> Optional[str]:
from_env = os.getenv("TOOT_LOG_FILE")
if from_env:
return from_env
return get_setting("common.debug_file", str)
@lru_cache(maxsize=None)
def get_quiet():
if "--quiet" in sys.argv:
return True
return get_setting("common.quiet", str, False)

View File

@ -3,7 +3,7 @@ import urwid
from concurrent.futures import ThreadPoolExecutor
from toot import api, config, __version__
from toot import api, config, __version__, settings
from toot.console import get_default_visibility
from toot.exceptions import ApiError
@ -14,13 +14,16 @@ from .overlays import ExceptionStackTrace, GotoMenu, Help, StatusSource, StatusL
from .overlays import StatusDeleteConfirmation, Account
from .poll import Poll
from .timeline import Timeline
from .utils import parse_content_links, show_media, copy_to_clipboard
from .utils import get_max_toot_chars, parse_content_links, show_media, copy_to_clipboard
logger = logging.getLogger(__name__)
urwid.set_encoding('UTF-8')
DEFAULT_MAX_TOOT_CHARS = 500
class Header(urwid.WidgetWrap):
def __init__(self, app, user):
self.app = app
@ -72,29 +75,51 @@ class Footer(urwid.Pile):
class TUI(urwid.Frame):
"""Main TUI frame."""
loop: urwid.MainLoop
screen: urwid.BaseScreen
@classmethod
def create(cls, app, user, args):
@staticmethod
def create(app, user, args):
"""Factory method, sets up TUI and an event loop."""
screen = TUI.create_screen(args)
tui = TUI(app, user, screen, args)
palette = PALETTE.copy()
overrides = settings.get_setting("tui.palette", dict, {})
for name, styles in overrides.items():
palette.append(tuple([name] + styles))
tui = cls(app, user, args)
loop = urwid.MainLoop(
tui,
palette=PALETTE,
palette=palette,
event_loop=urwid.AsyncioEventLoop(),
unhandled_input=tui.unhandled_input,
screen=screen,
)
tui.loop = loop
return tui
def __init__(self, app, user, args):
@staticmethod
def create_screen(args):
screen = urwid.raw_display.Screen()
# Determine how many colors to use
default_colors = 1 if args.no_color else 16
colors = settings.get_setting("tui.colors", int, default_colors)
logger.debug(f"Setting colors to {colors}")
screen.set_terminal_properties(colors)
return screen
def __init__(self, app, user, screen, args):
self.app = app
self.user = user
self.args = args
self.config = config.load_config()
self.loop = None # set in `create`
self.loop = None # late init, set in `create`
self.screen = screen
self.executor = ThreadPoolExecutor(max_workers=1)
self.timeline_generator = api.home_timeline_generator(app, user, limit=40)
@ -105,13 +130,12 @@ class TUI(urwid.Frame):
self.footer.set_status("Loading...")
# Default max status length, updated on startup
self.max_toot_chars = 500
self.max_toot_chars = DEFAULT_MAX_TOOT_CHARS
self.timeline = None
self.overlay = None
self.exception = None
self.can_translate = False
self.screen = urwid.raw_display.Screen()
self.account = None
super().__init__(self.body, header=self.header, footer=self.footer)
@ -282,8 +306,8 @@ class TUI(urwid.Frame):
return api.get_instance(self.app.base_url)
def _done(instance):
if "max_toot_chars" in instance:
self.max_toot_chars = instance["max_toot_chars"]
self.max_toot_chars = get_max_toot_chars(instance, DEFAULT_MAX_TOOT_CHARS)
logger.info(f"Max toot chars set to: {self.max_toot_chars}")
if "translation" in instance:
# instance is advertising translation service
@ -329,7 +353,7 @@ class TUI(urwid.Frame):
)
def clear_screen(self):
self.loop.screen.clear()
self.screen.clear()
def show_links(self, status):
links = parse_content_links(status.original.data["content"]) if status else []
@ -492,8 +516,8 @@ class TUI(urwid.Frame):
urwid.connect_signal(widget, "close", _close)
urwid.connect_signal(widget, "delete", _delete)
self.open_overlay(widget, title="Delete status?", options=dict(
align="center", width=("relative", 60),
valign="middle", height=5,
align="center", width=30,
valign="middle", height=4,
))
def post_status(self, content, warning, visibility, in_reply_to_id):
@ -518,11 +542,9 @@ class TUI(urwid.Frame):
def async_toggle_favourite(self, timeline, status):
def _favourite():
logger.info("Favouriting {}".format(status))
api.favourite(self.app, self.user, status.id)
def _unfavourite():
logger.info("Unfavouriting {}".format(status))
api.unfavourite(self.app, self.user, status.id)
def _done(loop):
@ -539,11 +561,9 @@ class TUI(urwid.Frame):
def async_toggle_reblog(self, timeline, status):
def _reblog():
logger.info("Reblogging {}".format(status))
api.reblog(self.app, self.user, status.original.id, visibility=get_default_visibility())
def _unreblog():
logger.info("Unreblogging {}".format(status))
api.unreblog(self.app, self.user, status.original.id)
def _done(loop):
@ -567,7 +587,6 @@ class TUI(urwid.Frame):
def async_translate(self, timeline, status):
def _translate():
logger.info("Translating {}".format(status))
self.footer.set_message("Translating status {}".format(status.original.id))
try:
@ -600,11 +619,9 @@ class TUI(urwid.Frame):
def async_toggle_bookmark(self, timeline, status):
def _bookmark():
logger.info("Bookmarking {}".format(status))
api.bookmark(self.app, self.user, status.id)
def _unbookmark():
logger.info("Unbookmarking {}".format(status))
api.unbookmark(self.app, self.user, status.id)
def _done(loop):
@ -705,7 +722,7 @@ class TUI(urwid.Frame):
if not self.overlay:
self.show_goto_menu()
elif key in ('h', 'H'):
elif key == '?':
if not self.overlay:
self.show_help()

View File

@ -66,8 +66,8 @@ class StatusComposer(urwid.Frame):
def generate_list_items(self):
if self.in_reply_to:
yield urwid.Text(("gray", "Replying to {}".format(self.in_reply_to.original.account)))
yield urwid.AttrWrap(urwid.Divider("-"), "gray")
yield urwid.Text(("dim", "Replying to {}".format(self.in_reply_to.original.account)))
yield urwid.AttrWrap(urwid.Divider("-"), "dim")
yield urwid.Text("Status message")
yield self.content_edit

View File

@ -1,8 +1,21 @@
# name, fg, bg, mono, fg_h, bg_h
# Color definitions are tuples of:
# - name
# - foreground (normal mode)
# - background (normal mode)
# - foreground (monochrome mode)
# - foreground (high color mode)
# - background (high color mode)
#
# See:
# http://urwid.org/tutorial/index.html#display-attributes
# http://urwid.org/manual/displayattributes.html#using-display-attributes
PALETTE = [
# Components
('button', 'white', 'black'),
('button_focused', 'light gray', 'dark magenta'),
('button_focused', 'light gray', 'dark magenta', 'bold,underline'),
('card_author', 'yellow', ''),
('card_title', 'dark green', ''),
('columns_divider', 'white', 'dark blue'),
('content_warning', 'white', 'dark magenta'),
('editbox', 'white', 'black'),
@ -12,54 +25,61 @@ PALETTE = [
('footer_status', 'white', 'dark blue'),
('footer_status_bold', 'white, bold', 'dark blue'),
('header', 'white', 'dark blue'),
('header_bold', 'white,bold', 'dark blue'),
('header_bold', 'white,bold', 'dark blue', 'bold'),
('intro_bigtext', 'yellow', ''),
('intro_smalltext', 'light blue', ''),
('poll_bar', 'white', 'dark blue'),
('status_detail_account', 'dark green', ''),
('status_detail_bookmarked', 'light red', ''),
('status_detail_timestamp', 'light blue', ''),
('status_list_account', 'dark green', ''),
('status_list_selected', 'white,bold', 'dark green', 'bold,underline'),
('status_list_timestamp', 'light blue', ''),
# Functional
('hashtag', 'light cyan,bold', ''),
('followed_hashtag', 'yellow,bold', ''),
('link', ',italics', ''),
('link_focused', ',italics', 'dark magenta'),
# Colors
('bold', ',bold', ''),
('blue', 'light blue', ''),
('blue_bold', 'light blue, bold', ''),
('blue_selected', 'white', 'dark blue'),
('cyan', 'dark cyan', ''),
('cyan_bold', 'dark cyan,bold', ''),
('gray', 'dark gray', ''),
('green', 'dark green', ''),
('green_selected', 'white,bold', 'dark green'),
('yellow', 'yellow', ''),
('yellow_bold', 'yellow,bold', ''),
('red', 'dark red', ''),
('account', 'dark green', ''),
('hashtag', 'light cyan,bold', '', 'bold'),
('hashtag_followed', 'yellow,bold', '', 'bold'),
('link', ',italics', '', ',italics'),
('link_focused', ',italics', 'dark magenta', "underline,italics"),
('shortcut', 'light blue', ''),
('shortcut_highlight', 'white,bold', '', 'bold'),
('warning', 'light red', ''),
('white_bold', 'white,bold', ''),
# Visiblity
('visibility_public', 'dark gray', ''),
('visibility_unlisted', 'white', ''),
('visibility_private', 'dark cyan', ''),
('visibility_direct', 'yellow', ''),
# Styles
('bold', ',bold', ''),
('dim', 'dark gray', ''),
('highlight', 'yellow', ''),
('success', 'dark green', ''),
# HTML tag styling
('a', ',italics', ''),
('a', ',italics', '', 'italics'),
# em tag is mapped to i
('i', ',italics', ''),
('i', ',italics', '', 'italics'),
# strong tag is mapped to b
('b', ',bold', ''),
('b', ',bold', '', 'bold'),
# special case for bold + italic nested tags
('bi', ',bold,italics', ''),
('u', ',underline', ''),
('del', ',strikethrough', ''),
('code', 'light gray, standout', ''),
('pre', 'light gray, standout', ''),
('blockquote', 'light gray', ''),
('h1', ',bold', ''),
('h2', ',bold', ''),
('h3', ',bold', ''),
('h4', ',bold', ''),
('h5', ',bold', ''),
('h6', ',bold', ''),
('class_mention_hashtag', 'light cyan,bold', ''),
('class_hashtag', 'light cyan,bold', ''),
('bi', ',bold,italics', '', ',bold,italics'),
('u', ',underline', '', ',underline'),
('del', ',strikethrough', '', ',strikethrough'),
('code', 'light gray, standout', '', ',standout'),
('pre', 'light gray, standout', '', ',standout'),
('blockquote', 'light gray', '', ''),
('h1', ',bold', '', ',bold'),
('h2', ',bold', '', ',bold'),
('h3', ',bold', '', ',bold'),
('h4', ',bold', '', ',bold'),
('h5', ',bold', '', ',bold'),
('h6', ',bold', '', ',bold'),
('class_mention_hashtag', 'light cyan,bold', '', ',bold'),
('class_hashtag', 'light cyan,bold', '', ',bold'),
]
VISIBILITY_OPTIONS = [

View File

@ -1,6 +1,6 @@
from collections import namedtuple
from .utils import parse_datetime
from toot.utils.datetime import parse_datetime
Author = namedtuple("Author", ["account", "display_name", "username"])

View File

@ -81,15 +81,15 @@ class StatusDeleteConfirmation(urwid.ListBox):
signals = ["delete", "close"]
def __init__(self, status):
yes = SelectableText("Yes, send it to heck")
no = SelectableText("No, I'll spare it for now")
def _delete(_):
self._emit("delete")
urwid.connect_signal(yes, "click", lambda *args: self._emit("delete"))
urwid.connect_signal(no, "click", lambda *args: self._emit("close"))
def _close(_):
self._emit("close")
walker = urwid.SimpleFocusListWalker([
urwid.AttrWrap(yes, "", "blue_selected"),
urwid.AttrWrap(no, "", "blue_selected"),
Button("Yes, delete", on_press=_delete),
Button("No, cancel", on_press=_close),
])
super().__init__(walker)
@ -196,9 +196,9 @@ class Help(urwid.Padding):
def generate_contents(self):
def h(text):
return highlight_keys(text, "cyan")
return highlight_keys(text, "shortcut")
yield urwid.Text(("yellow_bold", "toot {}".format(__version__)))
yield urwid.Text(("bold", "toot {}".format(__version__)))
yield urwid.Divider()
yield urwid.Text(("bold", "General usage"))
yield urwid.Divider()
@ -211,9 +211,9 @@ class Help(urwid.Padding):
yield urwid.Divider()
yield urwid.Text(h(" [Q] - quit toot"))
yield urwid.Text(h(" [G] - go to - switch timelines"))
yield urwid.Text(h(" [P] - save/unsave (pin) current timeline"))
yield urwid.Text(h(" [E] - save/unsave (pin) current timeline"))
yield urwid.Text(h(" [,] - refresh current timeline"))
yield urwid.Text(h(" [H] - show this help"))
yield urwid.Text(h(" [?] - show this help"))
yield urwid.Divider()
yield urwid.Text(("bold", "Status keys"))
yield urwid.Divider()
@ -262,10 +262,10 @@ class Account(urwid.ListBox):
yield Button("Cancel", on_press=cancel_action, user_data=self)
else:
if self.user.username == account["acct"]:
yield urwid.Text(("light gray", "This is your account"))
yield urwid.Text(("dim", "This is your account"))
else:
if relationship['requested']:
yield urwid.Text(("light gray", "< Follow request is pending >"))
yield urwid.Text(("dim", "< Follow request is pending >"))
else:
yield Button("Unfollow" if relationship['following'] else "Follow",
on_press=confirm_action, user_data=self)
@ -277,7 +277,7 @@ class Account(urwid.ListBox):
yield urwid.Divider("")
yield urwid.Divider()
yield urwid.Text([('green', f"@{account['acct']}"), f" {account['display_name']}"])
yield urwid.Text([("account", f"@{account['acct']}"), f" {account['display_name']}"])
if account["note"]:
yield urwid.Divider()
@ -287,12 +287,12 @@ class Account(urwid.ListBox):
yield (line)
yield urwid.Divider()
yield urwid.Text(["ID: ", ("green", f"{account['id']}")])
yield urwid.Text(["Since: ", ("green", f"{account['created_at'][:10]}")])
yield urwid.Text(["ID: ", ("highlight", f"{account['id']}")])
yield urwid.Text(["Since: ", ("highlight", f"{account['created_at'][:10]}")])
yield urwid.Divider()
if account["bot"]:
yield urwid.Text([("green", "Bot \N{robot face}")])
yield urwid.Text([("highlight", "Bot \N{robot face}")])
yield urwid.Divider()
if account["locked"]:
yield urwid.Text([("warning", "Locked \N{lock}")])
@ -301,28 +301,28 @@ class Account(urwid.ListBox):
yield urwid.Text([("warning", "Suspended \N{cross mark}")])
yield urwid.Divider()
if relationship["followed_by"]:
yield urwid.Text(("green", "Follows you \N{busts in silhouette}"))
yield urwid.Text(("highlight", "Follows you \N{busts in silhouette}"))
yield urwid.Divider()
if relationship["blocked_by"]:
yield urwid.Text(("warning", "Blocks you \N{no entry}"))
yield urwid.Divider()
yield urwid.Text(["Followers: ", ("yellow", f"{account['followers_count']}")])
yield urwid.Text(["Following: ", ("yellow", f"{account['following_count']}")])
yield urwid.Text(["Statuses: ", ("yellow", f"{account['statuses_count']}")])
yield urwid.Text(["Followers: ", ("highlight", f"{account['followers_count']}")])
yield urwid.Text(["Following: ", ("highlight", f"{account['following_count']}")])
yield urwid.Text(["Statuses: ", ("highlight", f"{account['statuses_count']}")])
if account["fields"]:
for field in account["fields"]:
name = field["name"].title()
yield urwid.Divider()
yield urwid.Text([("yellow", f"{name.rstrip(':')}"), ":"])
yield urwid.Text([("bold", f"{name.rstrip(':')}"), ":"])
widgetlist = parser.html_to_widgets(field["value"])
for line in widgetlist:
yield (line)
if field["verified_at"]:
yield urwid.Text(("green", "✓ Verified"))
yield urwid.Text(("success", "✓ Verified"))
yield urwid.Divider()
yield link("", account["url"])

View File

@ -2,7 +2,7 @@ import urwid
from toot import api
from toot.exceptions import ApiError
from .utils import parse_datetime
from toot.utils.datetime import parse_datetime
from .widgets import Button, CheckBox, RadioButton
from .richtext import ContentParser
@ -59,7 +59,7 @@ class Poll(urwid.ListBox):
if poll["voted"] or poll["expired"]:
prefix = "" if voted_for else " "
yield urwid.Text(("gray", prefix + f'{option["title"]}'))
yield urwid.Text(("dim", prefix + f'{option["title"]}'))
else:
if poll["multiple"]:
checkbox = CheckBox(f'{option["title"]}')
@ -81,7 +81,7 @@ class Poll(urwid.ListBox):
)
poll_detail += " · Closes on {}".format(expires_at)
yield urwid.Text(("gray", poll_detail))
yield urwid.Text(("dim", poll_detail))
def generate_contents(self, status):
yield urwid.Divider()

View File

@ -6,16 +6,17 @@ import webbrowser
from typing import List, Optional
from .entities import Status
from .scroll import Scrollable, ScrollBar
from .utils import parse_datetime, highlight_keys
from .widgets import SelectableText, SelectableColumns
from .richtext import ContentParser
from toot.tui import app
from toot.tui.utils import time_ago
from toot.utils.datetime import parse_datetime, time_ago
from toot.utils.language import language_name
from toot.entities import Status
from toot.tui.scroll import Scrollable, ScrollBar
from toot.tui.utils import highlight_keys
from toot.tui.widgets import SelectableText, SelectableColumns
from toot.tui.richtext import ContentParser
from toot.utils import urlencode_url
from .stubs.urwidgets import Hyperlink, TextEmbed, parse_text, has_urwidgets
from toot.tui.stubs.urwidgets import Hyperlink, TextEmbed, parse_text, has_urwidgets
logger = logging.getLogger("toot")
@ -55,7 +56,7 @@ class Timeline(urwid.Columns):
super().__init__([
("weight", 40, self.status_list),
("weight", 0, urwid.AttrWrap(urwid.SolidFill(""), "blue_selected")),
("weight", 0, urwid.AttrWrap(urwid.SolidFill(""), "columns_divider")),
("weight", 60, status_widget),
])
@ -82,16 +83,15 @@ class Timeline(urwid.Columns):
return urwid.ListBox(walker)
def build_list_item(self, status):
item = StatusListItem(status)
item = StatusListItem(status, self.tui.args.relative_datetimes)
urwid.connect_signal(item, "click", lambda *args:
self.tui.show_context_menu(status))
return urwid.AttrMap(item, None, focus_map={
"blue": "green_selected",
"green": "green_selected",
"yellow": "green_selected",
"cyan": "green_selected",
"red": "green_selected",
None: "green_selected",
"status_list_account": "status_list_selected",
"status_list_timestamp": "status_list_selected",
"highligh": "status_list_selected",
"dim": "status_list_selected",
None: "status_list_selected",
})
def get_option_text(self, status: Optional[Status]) -> Optional[urwid.Text]:
@ -108,17 +108,17 @@ class Timeline(urwid.Columns):
"[F]avourite",
"[V]iew",
"[T]hread" if not self.is_thread else "",
"[L]inks",
"L[i]nks",
"[R]eply",
"[P]oll" if poll and not poll["expired"] else "",
"So[u]rce",
"[Z]oom",
"Tra[n]slate" if self.tui.can_translate else "",
"Cop[y]",
"[H]elp",
"Help([?])",
]
options = "\n" + " ".join(o for o in options if o)
options = highlight_keys(options, "white_bold", "cyan")
options = highlight_keys(options, "shortcut_highlight", "shortcut")
return urwid.Text(options)
def get_focused_status(self):
@ -220,7 +220,7 @@ class Timeline(urwid.Columns):
self.tui.async_toggle_bookmark(self, status)
return
if key in ("l", "L"):
if key in ("i", "I"):
self.tui.show_links(status)
return
@ -338,13 +338,13 @@ class StatusDetails(urwid.Pile):
def content_generator(self, status, reblogged_by):
if reblogged_by:
text = "{} boosted".format(reblogged_by.display_name or reblogged_by.username)
yield ("pack", urwid.Text(("gray", text)))
yield ("pack", urwid.AttrMap(urwid.Divider("-"), "gray"))
yield ("pack", urwid.Text(("dim", text)))
yield ("pack", urwid.AttrMap(urwid.Divider("-"), "dim"))
if status.author.display_name:
yield ("pack", urwid.Text(("green", status.author.display_name)))
yield ("pack", urwid.Text(("bold", status.author.display_name)))
account_color = "yellow" if status.author.account in self.followed_accounts else "gray"
account_color = "highlight" if status.author.account in self.followed_accounts else "account"
yield ("pack", urwid.Text((account_color, status.author.account)))
yield ("pack", urwid.Divider())
@ -367,7 +367,7 @@ class StatusDetails(urwid.Pile):
media = status.data["media_attachments"]
if media:
for m in media:
yield ("pack", urwid.AttrMap(urwid.Divider("-"), "gray"))
yield ("pack", urwid.AttrMap(urwid.Divider("-"), "dim"))
yield ("pack", urwid.Text([("bold", "Media attachment"), " (", m["type"], ")"]))
if m["description"]:
yield ("pack", urwid.Text(m["description"]))
@ -386,7 +386,7 @@ class StatusDetails(urwid.Pile):
application = status.data.get("application") or {}
application = application.get("name")
yield ("pack", urwid.AttrWrap(urwid.Divider("-"), "gray"))
yield ("pack", urwid.AttrWrap(urwid.Divider("-"), "dim"))
translated_from = (
language_name(status.original.translated_from)
@ -395,24 +395,24 @@ class StatusDetails(urwid.Pile):
)
visibility_colors = {
"public": "gray",
"unlisted": "white",
"private": "cyan",
"direct": "yellow"
"public": "visibility_public",
"unlisted": "visibility_unlisted",
"private": "visibility_private",
"direct": "visibility_direct"
}
visibility = status.visibility.title()
visibility_color = visibility_colors.get(status.visibility, "gray")
visibility_color = visibility_colors.get(status.visibility, "dim")
yield ("pack", urwid.Text([
("blue", f"{status.created_at.strftime('%Y-%m-%d %H:%M')} "),
("red" if status.bookmarked else "gray", "b "),
("gray", f"{status.data['replies_count']} "),
("yellow" if status.reblogged else "gray", f"{status.data['reblogs_count']} "),
("yellow" if status.favourited else "gray", f"{status.data['favourites_count']}"),
("status_detail_timestamp", f"{status.created_at.strftime('%Y-%m-%d %H:%M')} "),
("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']} "),
("highlight" if status.favourited else "dim", f"{status.data['favourites_count']}"),
(visibility_color, f" · {visibility}"),
("yellow", f" · Translated from {translated_from} " if translated_from else ""),
("gray", f" · {application}" if application else ""),
("highlight", f" · Translated from {translated_from} " if translated_from else ""),
("dim", f" · {application}" if application else ""),
]))
# Push things to bottom
@ -424,9 +424,9 @@ class StatusDetails(urwid.Pile):
return urwid.LineBox(contents)
def card_generator(self, card):
yield urwid.Text(("green", card["title"].strip()))
yield urwid.Text(("card_title", card["title"].strip()))
if card.get("author_name"):
yield urwid.Text(["by ", ("yellow", card["author_name"].strip())])
yield urwid.Text(["by ", ("card_author", card["author_name"].strip())])
yield urwid.Text("")
if card["description"]:
yield urwid.Text(card["description"].strip())
@ -455,36 +455,36 @@ class StatusDetails(urwid.Pile):
expires_at = parse_datetime(poll["expires_at"]).strftime("%Y-%m-%d %H:%M")
status += " · Closes on {}".format(expires_at)
yield urwid.Text(("gray", status))
yield urwid.Text(("dim", status))
class StatusListItem(SelectableColumns):
def __init__(self, status):
def __init__(self, status, relative_datetimes):
edited_at = status.data.get("edited_at")
# TODO: hacky implementation to avoid creating conflicts for existing
# pull reuqests, refactor when merged.
created_at = (
time_ago(status.created_at).ljust(3, " ")
if "--relative-datetimes" in sys.argv
if relative_datetimes
else status.created_at.strftime("%Y-%m-%d %H:%M")
)
edited_flag = "*" if edited_at else " "
favourited = ("yellow", "") if status.original.favourited else " "
reblogged = ("yellow", "") if status.original.reblogged else " "
is_reblog = ("cyan", "") if status.reblog else " "
is_reply = ("cyan", "") if status.original.in_reply_to else " "
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 " "
return super().__init__([
("pack", SelectableText(("blue", created_at), wrap="clip")),
("pack", urwid.Text(("blue", edited_flag))),
("pack", SelectableText(("status_list_timestamp", created_at), wrap="clip")),
("pack", urwid.Text(("status_list_timestamp", edited_flag))),
("pack", urwid.Text(" ")),
("pack", urwid.Text(favourited)),
("pack", urwid.Text(" ")),
("pack", urwid.Text(reblogged)),
("pack", urwid.Text(" ")),
urwid.Text(("green", status.original.account), wrap="clip"),
urwid.Text(("status_list_account", status.original.account), wrap="clip"),
("pack", urwid.Text(is_reply)),
("pack", urwid.Text(is_reblog)),
("pack", urwid.Text(" ")),

View File

@ -1,60 +1,14 @@
import base64
import urwid
from html.parser import HTMLParser
import math
import os
import re
import shutil
import subprocess
import urwid
from datetime import datetime, timezone
from functools import reduce
from html.parser import HTMLParser
from typing import List
HASHTAG_PATTERN = re.compile(r'(?<!\w)(#\w+)\b')
SECOND = 1
MINUTE = SECOND * 60
HOUR = MINUTE * 60
DAY = HOUR * 24
WEEK = DAY * 7
def parse_datetime(value):
"""Returns an aware datetime in local timezone"""
# In Python < 3.7, `%z` does not match `Z` offset
# https://docs.python.org/3.7/library/datetime.html#strftime-and-strptime-behavior
if value.endswith("Z"):
dttm = datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%fZ").replace(tzinfo=timezone.utc)
else:
dttm = datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%f%z")
# When running tests return datetime in UTC so that tests don't depend on
# the local timezone
if "PYTEST_CURRENT_TEST" in os.environ:
return dttm.astimezone(timezone.utc)
return dttm.astimezone()
def time_ago(value: datetime) -> datetime:
now = datetime.now().astimezone()
delta = now.timestamp() - value.timestamp()
if delta < 1:
return "now"
if delta < 8 * DAY:
if delta < MINUTE:
return f"{math.floor(delta / SECOND)}".rjust(2, " ") + "s"
if delta < HOUR:
return f"{math.floor(delta / MINUTE)}".rjust(2, " ") + "m"
if delta < DAY:
return f"{math.floor(delta / HOUR)}".rjust(2, " ") + "h"
return f"{math.floor(delta / DAY)}".rjust(2, " ") + "d"
if delta < 53 * WEEK: # not exactly correct but good enough as a boundary
return f"{math.floor(delta / WEEK)}".rjust(2, " ") + "w"
return ">1y"
def highlight_keys(text, high_attr, low_attr=""):
@ -148,3 +102,26 @@ def copy_to_clipboard(screen: urwid.raw_display.Screen, text: str):
screen.write(f"\033]52;c;{b64_text}\a")
screen.flush()
def get_max_toot_chars(instance, default=500):
# Mastodon
# https://docs.joinmastodon.org/entities/Instance/#max_characters
max_toot_chars = deep_get(instance, ["configuration", "statuses", "max_characters"])
if isinstance(max_toot_chars, int):
return max_toot_chars
# Pleroma
max_toot_chars = instance.get("max_toot_chars")
if isinstance(max_toot_chars, int):
return max_toot_chars
return default
def deep_get(adict: dict, path: List[str], default=None):
return reduce(
lambda d, key: d.get(key, default) if isinstance(d, dict) else default,
path,
adict
)

147
toot/typing_compat.py Normal file
View File

@ -0,0 +1,147 @@
# Taken from https://github.com/rossmacarthur/typing-compat/
# TODO: Remove once the minimum python version is increased to 3.8
#
# Licensed under the MIT license
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# flake8: noqa
import collections
import typing
__all__ = ['get_args', 'get_origin']
__title__ = 'typing-compat'
__version__ = '0.1.0'
__url__ = 'https://github.com/rossmacarthur/typing-compat'
__author__ = 'Ross MacArthur'
__author_email__ = 'ross@macarthur.io'
__description__ = 'Python typing compatibility library'
try:
# Python >=3.8 should have these functions already
from typing import get_args as _get_args # novermin
from typing import get_origin as _get_origin # novermin
except ImportError:
if hasattr(typing, '_GenericAlias'): # Python 3.7
def _get_origin(tp):
"""Copied from the Python 3.8 typing module"""
if isinstance(tp, typing._GenericAlias):
return tp.__origin__
if tp is typing.Generic:
return typing.Generic
return None
def _get_args(tp):
"""Copied from the Python 3.8 typing module"""
if isinstance(tp, typing._GenericAlias):
res = tp.__args__
if (
get_origin(tp) is collections.abc.Callable
and res[0] is not Ellipsis
):
res = (list(res[:-1]), res[-1])
return res
return ()
else: # Python <3.7
def _resolve_via_mro(tp):
if hasattr(tp, '__mro__'):
for t in tp.__mro__:
if t.__module__ in ('builtins', '__builtin__') and t is not object:
return t
return tp
def _get_origin(tp):
"""Emulate the behaviour of Python 3.8 typing module"""
if isinstance(tp, typing._ClassVar):
return typing.ClassVar
elif isinstance(tp, typing._Union):
return typing.Union
elif isinstance(tp, typing.GenericMeta):
if hasattr(tp, '_gorg'):
return _resolve_via_mro(tp._gorg)
else:
while tp.__origin__ is not None:
tp = tp.__origin__
return _resolve_via_mro(tp)
elif hasattr(typing, '_Literal') and isinstance(tp, typing._Literal): # novermin
return typing.Literal # novermin
def _normalize_arg(args):
if isinstance(args, tuple) and len(args) > 1:
base, rest = args[0], tuple(_normalize_arg(arg) for arg in args[1:])
if isinstance(base, typing.CallableMeta):
return typing.Callable[list(rest[:-1]), rest[-1]]
elif isinstance(base, (typing.GenericMeta, typing._Union)):
return base[rest]
return args
def _get_args(tp):
"""Emulate the behaviour of Python 3.8 typing module"""
if isinstance(tp, typing._ClassVar):
return (tp.__type__,)
elif hasattr(tp, '_subs_tree'):
tree = tp._subs_tree()
if isinstance(tree, tuple) and len(tree) > 1:
if isinstance(tree[0], typing.CallableMeta) and len(tree) == 2:
return ([], _normalize_arg(tree[1]))
return tuple(_normalize_arg(arg) for arg in tree[1:])
return ()
def get_origin(tp):
"""
Get the unsubscripted version of a type.
This supports generic types, Callable, Tuple, Union, Literal, Final and
ClassVar. Returns None for unsupported types.
Examples:
get_origin(Literal[42]) is Literal
get_origin(int) is None
get_origin(ClassVar[int]) is ClassVar
get_origin(Generic) is Generic
get_origin(Generic[T]) is Generic
get_origin(Union[T, int]) is Union
get_origin(List[Tuple[T, T]][int]) == list
"""
return _get_origin(tp)
def get_args(tp):
"""
Get type arguments with all substitutions performed.
For unions, basic simplifications used by Union constructor are performed.
Examples:
get_args(Dict[str, int]) == (str, int)
get_args(int) == ()
get_args(Union[int, Union[T, int], str][int]) == (int, str)
get_args(Union[int, Tuple[T, int]][str]) == (int, Tuple[str, int])
get_args(Callable[[], T][int]) == ([], int)
"""
return _get_args(tp)

45
toot/utils/datetime.py Normal file
View File

@ -0,0 +1,45 @@
import math
import os
from datetime import datetime, timezone
def parse_datetime(value):
"""Returns an aware datetime in local timezone"""
dttm = datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%f%z")
# When running tests return datetime in UTC so that tests don't depend on
# the local timezone
if "PYTEST_CURRENT_TEST" in os.environ:
return dttm.astimezone(timezone.utc)
return dttm.astimezone()
SECOND = 1
MINUTE = SECOND * 60
HOUR = MINUTE * 60
DAY = HOUR * 24
WEEK = DAY * 7
def time_ago(value: datetime) -> str:
now = datetime.now().astimezone()
delta = now.timestamp() - value.timestamp()
if delta < 1:
return "now"
if delta < 8 * DAY:
if delta < MINUTE:
return f"{math.floor(delta / SECOND)}".rjust(2, " ") + "s"
if delta < HOUR:
return f"{math.floor(delta / MINUTE)}".rjust(2, " ") + "m"
if delta < DAY:
return f"{math.floor(delta / HOUR)}".rjust(2, " ") + "h"
return f"{math.floor(delta / DAY)}".rjust(2, " ") + "d"
if delta < 53 * WEEK: # not exactly correct but good enough as a boundary
return f"{math.floor(delta / WEEK)}".rjust(2, " ") + "w"
return ">1y"