Merge branch 'master' into rt-hyperlinks-urwidgets-stub
This commit is contained in:
commit
c74539f147
|
@ -25,7 +25,7 @@ jobs:
|
||||||
pytest
|
pytest
|
||||||
- name: Validate minimum required version
|
- name: Validate minimum required version
|
||||||
run: |
|
run: |
|
||||||
vermin --target=3.6 --no-tips .
|
vermin --target=3.7 --no-tips .
|
||||||
- name: Check style
|
- name: Check style
|
||||||
run: |
|
run: |
|
||||||
flake8
|
flake8
|
||||||
|
|
20
CHANGELOG.md
20
CHANGELOG.md
|
@ -3,11 +3,29 @@ Changelog
|
||||||
|
|
||||||
<!-- Do not edit. This file is automatically generated from changelog.yaml.-->
|
<!-- 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
|
* Add `timeline --account` option to show the account timeline (thanks Dan
|
||||||
Schwarz)
|
Schwarz)
|
||||||
|
* Add `toot status` command to show a single status
|
||||||
* TUI: Add personal timeline (thanks Dan Schwarz)
|
* 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)**
|
**0.36.0 (2023-03-09)**
|
||||||
|
|
||||||
|
|
2
Makefile
2
Makefile
|
@ -10,7 +10,7 @@ publish :
|
||||||
test:
|
test:
|
||||||
pytest tests/*.py -v
|
pytest tests/*.py -v
|
||||||
flake8
|
flake8
|
||||||
vermin --target=3.6 --no-tips --violations --exclude-regex venv/.* .
|
vermin --target=3.7 --no-tips --violations --exclude-regex venv/.* .
|
||||||
|
|
||||||
coverage:
|
coverage:
|
||||||
coverage erase
|
coverage erase
|
||||||
|
|
|
@ -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:
|
0.37.0:
|
||||||
date: "TBA"
|
date: 2023-06-28
|
||||||
changes:
|
changes:
|
||||||
- "**BREAKING:** Require Python 3.7+"
|
- "**BREAKING:** Require Python 3.7+"
|
||||||
- "Add `timeline --account` option to show the account timeline (thanks Dan Schwarz)"
|
- "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: 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:
|
0.36.0:
|
||||||
date: 2023-03-09
|
date: 2023-03-09
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
- [Installation](installation.md)
|
- [Installation](installation.md)
|
||||||
- [Usage](usage.md)
|
- [Usage](usage.md)
|
||||||
- [Advanced](advanced.md)
|
- [Advanced](advanced.md)
|
||||||
|
- [Settings](settings.md)
|
||||||
- [TUI](tui.md)
|
- [TUI](tui.md)
|
||||||
- [Contributing](contributing.md)
|
- [Contributing](contributing.md)
|
||||||
- [Documentation](documentation.md)
|
- [Documentation](documentation.md)
|
||||||
|
|
|
@ -21,10 +21,10 @@ through the specified server.
|
||||||
|
|
||||||
For example:
|
For example:
|
||||||
|
|
||||||
.. code-block:: sh
|
```sh
|
||||||
|
export HTTPS_PROXY="http://1.2.3.4:5678"
|
||||||
export HTTPS_PROXY="http://1.2.3.4:5678"
|
toot login --instance mastodon.social
|
||||||
toot login --instance mastodon.social
|
```
|
||||||
|
|
||||||
**NB:** This feature is provided by
|
**NB:** This feature is provided by
|
||||||
[requests](http://docs.python-requests.org/en/master/user/advanced/#proxies>)
|
[requests](http://docs.python-requests.org/en/master/user/advanced/#proxies>)
|
||||||
|
|
|
@ -3,11 +3,29 @@ Changelog
|
||||||
|
|
||||||
<!-- Do not edit. This file is automatically generated from changelog.yaml.-->
|
<!-- 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
|
* Add `timeline --account` option to show the account timeline (thanks Dan
|
||||||
Schwarz)
|
Schwarz)
|
||||||
|
* Add `toot status` command to show a single status
|
||||||
* TUI: Add personal timeline (thanks Dan Schwarz)
|
* 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)**
|
**0.36.0 (2023-03-09)**
|
||||||
|
|
||||||
|
|
|
@ -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"]
|
||||||
|
```
|
3
setup.py
3
setup.py
|
@ -12,7 +12,7 @@ and blocking accounts and other actions.
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
name='toot',
|
name='toot',
|
||||||
version='0.36.0',
|
version='0.38.1',
|
||||||
description='Mastodon CLI client',
|
description='Mastodon CLI client',
|
||||||
long_description=long_description.strip(),
|
long_description=long_description.strip(),
|
||||||
author='Ivan Habunek',
|
author='Ivan Habunek',
|
||||||
|
@ -39,6 +39,7 @@ setup(
|
||||||
"wcwidth>=0.1.7",
|
"wcwidth>=0.1.7",
|
||||||
"urwid>=2.0.0,<3.0",
|
"urwid>=2.0.0,<3.0",
|
||||||
"urwidgets>=0.1,<0.2",
|
"urwidgets>=0.1,<0.2",
|
||||||
|
"tomlkit>=0.10.0,<1.0"
|
||||||
],
|
],
|
||||||
entry_points={
|
entry_points={
|
||||||
'console_scripts': [
|
'console_scripts': [
|
||||||
|
|
|
@ -8,7 +8,7 @@ To enable integration tests, export the following environment variables to match
|
||||||
your test server and database:
|
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"
|
export TOOT_TEST_DATABASE_DSN="dbname=mastodon_development"
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
|
@ -26,6 +26,11 @@ from toot.exceptions import ApiError, ConsoleError
|
||||||
from toot.output import print_out
|
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
|
# Mastodon database name, used to confirm user registration without having to click the link
|
||||||
DATABASE_DSN = os.getenv("TOOT_TEST_DATABASE_DSN")
|
DATABASE_DSN = os.getenv("TOOT_TEST_DATABASE_DSN")
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import re
|
||||||
|
from uuid import uuid4
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from toot import api
|
from toot import api
|
||||||
|
@ -80,3 +82,35 @@ def test_tags(run, base_url):
|
||||||
|
|
||||||
out = run("tags_followed")
|
out = run("tags_followed")
|
||||||
assert out == f"* #bar\t{base_url}/tags/bar"
|
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]
|
||||||
|
|
|
@ -161,6 +161,7 @@ def test_timeline_with_re(mock_get, monkeypatch, capsys):
|
||||||
'acct': 'fz'
|
'acct': 'fz'
|
||||||
},
|
},
|
||||||
'reblog': {
|
'reblog': {
|
||||||
|
'created_at': '2017-04-12T15:53:18.174Z',
|
||||||
'account': {
|
'account': {
|
||||||
'display_name': 'Johnny Cash',
|
'display_name': 'Johnny Cash',
|
||||||
'acct': 'jc'
|
'acct': 'jc'
|
||||||
|
@ -179,8 +180,8 @@ def test_timeline_with_re(mock_get, monkeypatch, capsys):
|
||||||
out, err = capsys.readouterr()
|
out, err = capsys.readouterr()
|
||||||
lines = uncolorize(out).split("\n")
|
lines = uncolorize(out).split("\n")
|
||||||
|
|
||||||
assert "Frank Zappa" in lines[1]
|
assert "Johnny Cash" in lines[1]
|
||||||
assert "@fz" in lines[1]
|
assert "@jc" in lines[1]
|
||||||
assert "2017-04-12 15:53 UTC" in lines[1]
|
assert "2017-04-12 15:53 UTC" in lines[1]
|
||||||
|
|
||||||
assert (
|
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)
|
"exact mathematical design, but\nwhat's missing is the eyebrows." in out)
|
||||||
|
|
||||||
assert "111111111111111111" in lines[-3]
|
assert "111111111111111111" in lines[-3]
|
||||||
assert "↻ Reblogged @jc" in lines[-3]
|
assert "↻ @fz boosted" in lines[-3]
|
||||||
|
|
||||||
assert err == ""
|
assert err == ""
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,10 @@
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from os.path import join, expanduser
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
|
|
||||||
__version__ = '0.36.0'
|
__version__ = '0.38.1'
|
||||||
|
|
||||||
App = namedtuple('App', ['instance', 'base_url', 'client_id', 'client_secret'])
|
App = namedtuple('App', ['instance', 'base_url', 'client_id', 'client_secret'])
|
||||||
User = namedtuple('User', ['instance', 'username', 'access_token'])
|
User = namedtuple('User', ['instance', 'username', 'access_token'])
|
||||||
|
@ -9,3 +13,22 @@ DEFAULT_INSTANCE = 'https://mastodon.social'
|
||||||
|
|
||||||
CLIENT_NAME = 'toot - a Mastodon CLI client'
|
CLIENT_NAME = 'toot - a Mastodon CLI client'
|
||||||
CLIENT_WEBSITE = 'https://github.com/ihabunek/toot'
|
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)
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
from .console import main
|
||||||
|
|
||||||
|
main()
|
|
@ -521,6 +521,10 @@ def unmute(app, user, account):
|
||||||
return _account_action(app, user, account, 'unmute')
|
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):
|
def block(app, user, account):
|
||||||
return _account_action(app, user, account, 'block')
|
return _account_action(app, user, account, 'block')
|
||||||
|
|
||||||
|
@ -529,6 +533,10 @@ def unblock(app, user, account):
|
||||||
return _account_action(app, user, account, 'unblock')
|
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):
|
def verify_credentials(app, user):
|
||||||
return http.get(app, user, '/api/v1/accounts/verify_credentials').json()
|
return http.get(app, user, '/api/v1/accounts/verify_credentials').json()
|
||||||
|
|
||||||
|
|
|
@ -6,12 +6,13 @@ from datetime import datetime, timedelta, timezone
|
||||||
from time import sleep, time
|
from time import sleep, time
|
||||||
from toot import api, config, __version__
|
from toot import api, config, __version__
|
||||||
from toot.auth import login_interactive, login_browser_interactive, create_app_interactive
|
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.exceptions import ApiError, ConsoleError
|
||||||
from toot.output import (print_lists, print_out, print_instance, print_account, print_acct_list,
|
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)
|
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 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):
|
def get_timeline_generator(app, user, args):
|
||||||
|
@ -56,7 +57,8 @@ def timeline(app, user, args, generator=None):
|
||||||
if args.reverse:
|
if args.reverse:
|
||||||
items = reversed(items)
|
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():
|
if args.once or not sys.stdout.isatty():
|
||||||
break
|
break
|
||||||
|
@ -66,6 +68,12 @@ def timeline(app, user, args, generator=None):
|
||||||
break
|
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):
|
def thread(app, user, args):
|
||||||
toot = api.single_status(app, user, args.status_id)
|
toot = api.single_status(app, user, args.status_id)
|
||||||
context = api.context(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']:
|
for item in context['descendants']:
|
||||||
thread.append(item)
|
thread.append(item)
|
||||||
|
|
||||||
print_timeline(thread)
|
statuses = [from_dict(Status, s) for s in thread]
|
||||||
|
print_timeline(statuses)
|
||||||
|
|
||||||
|
|
||||||
def post(app, user, args):
|
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))
|
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):
|
def block(app, user, args):
|
||||||
account = api.find_account(app, user, args.account)
|
account = api.find_account(app, user, args.account)
|
||||||
api.block(app, user, account['id'])
|
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))
|
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):
|
def whoami(app, user, args):
|
||||||
account = api.verify_credentials(app, user)
|
account = api.verify_credentials(app, user)
|
||||||
print_account(account)
|
print_account(account)
|
||||||
|
@ -515,6 +534,7 @@ def instance(app, user, args):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
instance = api.get_instance(base_url)
|
instance = api.get_instance(base_url)
|
||||||
|
instance = from_dict(Instance, instance)
|
||||||
print_instance(instance)
|
print_instance(instance)
|
||||||
except ApiError:
|
except ApiError:
|
||||||
raise ConsoleError(
|
raise ConsoleError(
|
||||||
|
@ -542,6 +562,7 @@ def notifications(app, user, args):
|
||||||
if args.reverse:
|
if args.reverse:
|
||||||
notifications = reversed(notifications)
|
notifications = reversed(notifications)
|
||||||
|
|
||||||
|
notifications = [from_dict(Notification, n) for n in notifications]
|
||||||
print_notifications(notifications)
|
print_notifications(notifications)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,44 +1,22 @@
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
|
|
||||||
from functools import wraps
|
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.exceptions import ConsoleError
|
||||||
from toot.output import print_out
|
from toot.output import print_out
|
||||||
|
|
||||||
|
|
||||||
TOOT_CONFIG_DIR_NAME = "toot"
|
|
||||||
TOOT_CONFIG_FILE_NAME = "config.json"
|
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():
|
def get_config_file_path():
|
||||||
"""Returns the path to toot config file."""
|
"""Returns the path to toot config file."""
|
||||||
return join(get_config_dir(), TOOT_CONFIG_FILE_NAME)
|
return join(get_config_dir(), TOOT_CONFIG_FILE_NAME)
|
||||||
|
|
||||||
|
|
||||||
CONFIG_FILE = get_config_file_path()
|
|
||||||
|
|
||||||
|
|
||||||
def user_id(user):
|
def user_id(user):
|
||||||
return "{}@{}".format(user.username, user.instance)
|
return "{}@{}".format(user.username, user.instance)
|
||||||
|
|
||||||
|
@ -63,15 +41,18 @@ def make_config(path):
|
||||||
|
|
||||||
|
|
||||||
def load_config():
|
def load_config():
|
||||||
if not os.path.exists(CONFIG_FILE):
|
path = get_config_file_path()
|
||||||
make_config(CONFIG_FILE)
|
|
||||||
|
|
||||||
with open(CONFIG_FILE) as f:
|
if not os.path.exists(path):
|
||||||
|
make_config(path)
|
||||||
|
|
||||||
|
with open(path) as f:
|
||||||
return json.load(f)
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
def save_config(config):
|
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)
|
return json.dump(config, f, indent=True, sort_keys=True)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -7,9 +7,10 @@ import sys
|
||||||
from argparse import ArgumentParser, FileType, ArgumentTypeError, Action
|
from argparse import ArgumentParser, FileType, ArgumentTypeError, Action
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
from itertools import chain
|
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.exceptions import ApiError, ConsoleError
|
||||||
from toot.output import print_out, print_err
|
from toot.output import print_out, print_err
|
||||||
|
from toot.settings import get_setting
|
||||||
|
|
||||||
VISIBILITY_CHOICES = ["public", "unlisted", "private", "direct"]
|
VISIBILITY_CHOICES = ["public", "unlisted", "private", "direct"]
|
||||||
VISIBILITY_CHOICES_STR = ", ".join(f"'{v}'" for v in VISIBILITY_CHOICES)
|
VISIBILITY_CHOICES_STR = ", ".join(f"'{v}'" for v in VISIBILITY_CHOICES)
|
||||||
|
@ -459,6 +460,16 @@ READ_COMMANDS = [
|
||||||
],
|
],
|
||||||
require_auth=True,
|
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(
|
Command(
|
||||||
name="timeline",
|
name="timeline",
|
||||||
description="Show recent items in a timeline (home by default)",
|
description="Show recent items in a timeline (home by default)",
|
||||||
|
@ -693,6 +704,12 @@ ACCOUNTS_COMMANDS = [
|
||||||
],
|
],
|
||||||
require_auth=True,
|
require_auth=True,
|
||||||
),
|
),
|
||||||
|
Command(
|
||||||
|
name="muted",
|
||||||
|
description="List muted accounts",
|
||||||
|
arguments=[],
|
||||||
|
require_auth=True,
|
||||||
|
),
|
||||||
Command(
|
Command(
|
||||||
name="block",
|
name="block",
|
||||||
description="Block an account",
|
description="Block an account",
|
||||||
|
@ -709,6 +726,12 @@ ACCOUNTS_COMMANDS = [
|
||||||
],
|
],
|
||||||
require_auth=True,
|
require_auth=True,
|
||||||
),
|
),
|
||||||
|
Command(
|
||||||
|
name="blocked",
|
||||||
|
description="List blocked accounts",
|
||||||
|
arguments=[],
|
||||||
|
require_auth=True,
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
TAG_COMMANDS = [
|
TAG_COMMANDS = [
|
||||||
|
@ -872,12 +895,24 @@ def get_argument_parser(name, command):
|
||||||
if command.require_auth:
|
if command.require_auth:
|
||||||
combined_args += common_auth_args
|
combined_args += common_auth_args
|
||||||
|
|
||||||
|
defaults = get_setting(f"commands.{name}", dict, {})
|
||||||
|
|
||||||
for args, kwargs in combined_args:
|
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)
|
parser.add_argument(*args, **kwargs)
|
||||||
|
|
||||||
return parser
|
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):
|
def run_command(app, user, name, args):
|
||||||
command = next((c for c in COMMANDS if c.name == name), None)
|
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():
|
def main():
|
||||||
# Enable debug logging if --debug is in args
|
if settings.get_debug():
|
||||||
if "--debug" in sys.argv:
|
filename = settings.get_debug_file()
|
||||||
filename = os.getenv("TOOT_LOG_FILE")
|
|
||||||
logging.basicConfig(level=logging.DEBUG, filename=filename)
|
logging.basicConfig(level=logging.DEBUG, filename=filename)
|
||||||
logging.getLogger("urllib3").setLevel(logging.INFO)
|
logging.getLogger("urllib3").setLevel(logging.INFO)
|
||||||
|
|
||||||
|
|
|
@ -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
|
124
toot/output.py
124
toot/output.py
|
@ -3,12 +3,13 @@ import re
|
||||||
import sys
|
import sys
|
||||||
import textwrap
|
import textwrap
|
||||||
|
|
||||||
from typing import List
|
from functools import lru_cache
|
||||||
from wcwidth import wcswidth
|
from toot import settings
|
||||||
|
from toot.entities import Instance, Notification, Poll, Status
|
||||||
from toot.tui.utils import parse_datetime
|
|
||||||
from toot.utils import get_text, parse_html
|
from toot.utils import get_text, parse_html
|
||||||
from toot.wcstring import wc_wrap
|
from toot.wcstring import wc_wrap
|
||||||
|
from typing import List
|
||||||
|
from wcwidth import wcswidth
|
||||||
|
|
||||||
|
|
||||||
STYLES = {
|
STYLES = {
|
||||||
|
@ -100,6 +101,7 @@ def strip_tags(message):
|
||||||
return re.sub(STYLE_TAG_PATTERN, "", message)
|
return re.sub(STYLE_TAG_PATTERN, "", message)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=None)
|
||||||
def use_ansi_color():
|
def use_ansi_color():
|
||||||
"""Returns True if ANSI color codes should be used."""
|
"""Returns True if ANSI color codes should be used."""
|
||||||
|
|
||||||
|
@ -116,45 +118,44 @@ def use_ansi_color():
|
||||||
if "--no-color" in sys.argv:
|
if "--no-color" in sys.argv:
|
||||||
return False
|
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
|
return True
|
||||||
|
|
||||||
|
|
||||||
USE_ANSI_COLOR = use_ansi_color()
|
|
||||||
|
|
||||||
QUIET = "--quiet" in sys.argv
|
|
||||||
|
|
||||||
|
|
||||||
def print_out(*args, **kwargs):
|
def print_out(*args, **kwargs):
|
||||||
if not QUIET:
|
if not settings.get_quiet():
|
||||||
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, **kwargs)
|
print(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def print_err(*args, **kwargs):
|
def print_err(*args, **kwargs):
|
||||||
args = [f"<red>{a}</red>" for a in args]
|
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)
|
print(*args, file=sys.stderr, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def print_instance(instance):
|
def print_instance(instance: Instance):
|
||||||
print_out(f"<green>{instance['title']}</green>")
|
print_out(f"<green>{instance.title}</green>")
|
||||||
print_out(f"<blue>{instance['uri']}</blue>")
|
print_out(f"<blue>{instance.uri}</blue>")
|
||||||
print_out(f"running Mastodon {instance['version']}")
|
print_out(f"running Mastodon {instance.version}")
|
||||||
print_out()
|
print_out()
|
||||||
|
|
||||||
description = instance.get("description")
|
if instance.description:
|
||||||
if description:
|
for paragraph in re.split(r"[\r\n]+", instance.description.strip()):
|
||||||
for paragraph in re.split(r"[\r\n]+", description.strip()):
|
|
||||||
paragraph = get_text(paragraph)
|
paragraph = get_text(paragraph)
|
||||||
print_out(textwrap.fill(paragraph, width=80))
|
print_out(textwrap.fill(paragraph, width=80))
|
||||||
print_out()
|
print_out()
|
||||||
|
|
||||||
rules = instance.get("rules")
|
if instance.rules:
|
||||||
if rules:
|
|
||||||
print_out("Rules:")
|
print_out("Rules:")
|
||||||
for ordinal, rule in enumerate(rules):
|
for ordinal, rule in enumerate(instance.rules):
|
||||||
ordinal = f"{ordinal + 1}."
|
ordinal = f"{ordinal + 1}."
|
||||||
lines = textwrap.wrap(rule["text"], 80 - len(ordinal))
|
lines = textwrap.wrap(rule.text, 80 - len(ordinal))
|
||||||
first = True
|
first = True
|
||||||
for line in lines:
|
for line in lines:
|
||||||
if first:
|
if first:
|
||||||
|
@ -162,6 +163,11 @@ def print_instance(instance):
|
||||||
first = False
|
first = False
|
||||||
else:
|
else:
|
||||||
print_out(f"{' ' * len(ordinal)} {line}")
|
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):
|
def print_account(account):
|
||||||
|
@ -269,20 +275,18 @@ def print_search_results(results):
|
||||||
print_out("<yellow>Nothing found</yellow>")
|
print_out("<yellow>Nothing found</yellow>")
|
||||||
|
|
||||||
|
|
||||||
def print_status(status, width):
|
def print_status(status: Status, width: int = 80):
|
||||||
reblog = status['reblog']
|
status_id = status.id
|
||||||
content = reblog['content'] if reblog else status['content']
|
in_reply_to_id = status.in_reply_to_id
|
||||||
media_attachments = reblog['media_attachments'] if reblog else status['media_attachments']
|
reblogged_by = status.account if status.reblog else None
|
||||||
in_reply_to = status['in_reply_to_id']
|
|
||||||
poll = reblog.get('poll') if reblog else status.get('poll')
|
|
||||||
|
|
||||||
time = parse_datetime(status['created_at'])
|
status = status.original
|
||||||
time = time.strftime('%Y-%m-%d %H:%M %Z')
|
|
||||||
|
|
||||||
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
|
spacing = width - wcswidth(username) - wcswidth(time) - 2
|
||||||
|
|
||||||
display_name = status['account']['display_name']
|
display_name = status.account.display_name
|
||||||
if display_name:
|
if display_name:
|
||||||
spacing -= wcswidth(display_name) + 1
|
spacing -= wcswidth(display_name) + 1
|
||||||
|
|
||||||
|
@ -294,23 +298,24 @@ def print_status(status, width):
|
||||||
)
|
)
|
||||||
|
|
||||||
print_out("")
|
print_out("")
|
||||||
print_html(content, width)
|
print_html(status.content, width)
|
||||||
|
|
||||||
if media_attachments:
|
if status.media_attachments:
|
||||||
print_out("\nMedia:")
|
print_out("\nMedia:")
|
||||||
for attachment in media_attachments:
|
for attachment in status.media_attachments:
|
||||||
url = attachment["url"]
|
url = attachment.url
|
||||||
for line in wc_wrap(url, width):
|
for line in wc_wrap(url, width):
|
||||||
print_out(line)
|
print_out(line)
|
||||||
|
|
||||||
if poll:
|
if status.poll:
|
||||||
print_poll(poll)
|
print_poll(status.poll)
|
||||||
|
|
||||||
print_out()
|
print_out()
|
||||||
|
|
||||||
print_out(
|
print_out(
|
||||||
f"ID <yellow>{status['id']}</yellow> ",
|
f"ID <yellow>{status_id}</yellow> ",
|
||||||
f"↲ In reply to <yellow>{in_reply_to}</yellow> " if in_reply_to else "",
|
f"↲ In reply to <yellow>{in_reply_to_id}</yellow> " if in_reply_to_id else "",
|
||||||
f"↻ Reblogged <blue>@{reblog['account']['acct']}</blue> " if reblog else "",
|
f"↻ <blue>@{reblogged_by.acct}</blue> boosted " if reblogged_by else "",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -325,33 +330,33 @@ def print_html(text, width=80):
|
||||||
first = False
|
first = False
|
||||||
|
|
||||||
|
|
||||||
def print_poll(poll):
|
def print_poll(poll: Poll):
|
||||||
print_out()
|
print_out()
|
||||||
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"] else 0)
|
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>"
|
voted_for = " <yellow>✓</yellow>"
|
||||||
else:
|
else:
|
||||||
voted_for = ""
|
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"
|
poll_footer += " · Closed"
|
||||||
|
|
||||||
if poll["expires_at"]:
|
if poll.expires_at:
|
||||||
expires_at = parse_datetime(poll["expires_at"]).strftime("%Y-%m-%d %H:%M")
|
expires_at = poll.expires_at.strftime("%Y-%m-%d %H:%M")
|
||||||
poll_footer += f" · Closes on {expires_at}"
|
poll_footer += f" · Closes on {expires_at}"
|
||||||
|
|
||||||
print_out()
|
print_out()
|
||||||
print_out(poll_footer)
|
print_out(poll_footer)
|
||||||
|
|
||||||
|
|
||||||
def print_timeline(items, width=100):
|
def print_timeline(items: List[Status], width=100):
|
||||||
print_out("─" * width)
|
print_out("─" * width)
|
||||||
for item in items:
|
for item in items:
|
||||||
print_status(item, width)
|
print_status(item, width)
|
||||||
|
@ -366,20 +371,19 @@ notification_msgs = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def print_notification(notification, width=100):
|
def print_notification(notification: Notification, width=100):
|
||||||
account = "{display_name} @{acct}".format(**notification["account"])
|
account = f"{notification.account.display_name} @{notification.account.acct}"
|
||||||
msg = notification_msgs.get(notification["type"])
|
msg = notification_msgs.get(notification.type)
|
||||||
if msg is None:
|
if msg is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
print_out("─" * width)
|
print_out("─" * width)
|
||||||
print_out(msg.format(account=account))
|
print_out(msg.format(account=account))
|
||||||
status = notification.get("status")
|
if notification.status:
|
||||||
if status is not None:
|
print_status(notification.status, width)
|
||||||
print_status(status, width)
|
|
||||||
|
|
||||||
|
|
||||||
def print_notifications(notifications, width=100):
|
def print_notifications(notifications: List[Notification], width=100):
|
||||||
for notification in notifications:
|
for notification in notifications:
|
||||||
print_notification(notification)
|
print_notification(notification)
|
||||||
print_out("─" * width)
|
print_out("─" * width)
|
||||||
|
|
|
@ -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)
|
|
@ -3,7 +3,7 @@ import urwid
|
||||||
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
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.console import get_default_visibility
|
||||||
from toot.exceptions import ApiError
|
from toot.exceptions import ApiError
|
||||||
|
|
||||||
|
@ -14,13 +14,16 @@ from .overlays import ExceptionStackTrace, GotoMenu, Help, StatusSource, StatusL
|
||||||
from .overlays import StatusDeleteConfirmation, Account
|
from .overlays import StatusDeleteConfirmation, Account
|
||||||
from .poll import Poll
|
from .poll import Poll
|
||||||
from .timeline import Timeline
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
urwid.set_encoding('UTF-8')
|
urwid.set_encoding('UTF-8')
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_MAX_TOOT_CHARS = 500
|
||||||
|
|
||||||
|
|
||||||
class Header(urwid.WidgetWrap):
|
class Header(urwid.WidgetWrap):
|
||||||
def __init__(self, app, user):
|
def __init__(self, app, user):
|
||||||
self.app = app
|
self.app = app
|
||||||
|
@ -72,29 +75,51 @@ class Footer(urwid.Pile):
|
||||||
|
|
||||||
class TUI(urwid.Frame):
|
class TUI(urwid.Frame):
|
||||||
"""Main TUI frame."""
|
"""Main TUI frame."""
|
||||||
|
loop: urwid.MainLoop
|
||||||
|
screen: urwid.BaseScreen
|
||||||
|
|
||||||
@classmethod
|
@staticmethod
|
||||||
def create(cls, app, user, args):
|
def create(app, user, args):
|
||||||
"""Factory method, sets up TUI and an event loop."""
|
"""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(
|
loop = urwid.MainLoop(
|
||||||
tui,
|
tui,
|
||||||
palette=PALETTE,
|
palette=palette,
|
||||||
event_loop=urwid.AsyncioEventLoop(),
|
event_loop=urwid.AsyncioEventLoop(),
|
||||||
unhandled_input=tui.unhandled_input,
|
unhandled_input=tui.unhandled_input,
|
||||||
|
screen=screen,
|
||||||
)
|
)
|
||||||
tui.loop = loop
|
tui.loop = loop
|
||||||
|
|
||||||
return tui
|
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.app = app
|
||||||
self.user = user
|
self.user = user
|
||||||
self.args = args
|
self.args = args
|
||||||
self.config = config.load_config()
|
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.executor = ThreadPoolExecutor(max_workers=1)
|
||||||
self.timeline_generator = api.home_timeline_generator(app, user, limit=40)
|
self.timeline_generator = api.home_timeline_generator(app, user, limit=40)
|
||||||
|
|
||||||
|
@ -105,13 +130,12 @@ class TUI(urwid.Frame):
|
||||||
self.footer.set_status("Loading...")
|
self.footer.set_status("Loading...")
|
||||||
|
|
||||||
# Default max status length, updated on startup
|
# Default max status length, updated on startup
|
||||||
self.max_toot_chars = 500
|
self.max_toot_chars = DEFAULT_MAX_TOOT_CHARS
|
||||||
|
|
||||||
self.timeline = None
|
self.timeline = None
|
||||||
self.overlay = None
|
self.overlay = None
|
||||||
self.exception = None
|
self.exception = None
|
||||||
self.can_translate = False
|
self.can_translate = False
|
||||||
self.screen = urwid.raw_display.Screen()
|
|
||||||
self.account = None
|
self.account = None
|
||||||
|
|
||||||
super().__init__(self.body, header=self.header, footer=self.footer)
|
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)
|
return api.get_instance(self.app.base_url)
|
||||||
|
|
||||||
def _done(instance):
|
def _done(instance):
|
||||||
if "max_toot_chars" in instance:
|
self.max_toot_chars = get_max_toot_chars(instance, DEFAULT_MAX_TOOT_CHARS)
|
||||||
self.max_toot_chars = instance["max_toot_chars"]
|
logger.info(f"Max toot chars set to: {self.max_toot_chars}")
|
||||||
|
|
||||||
if "translation" in instance:
|
if "translation" in instance:
|
||||||
# instance is advertising translation service
|
# instance is advertising translation service
|
||||||
|
@ -329,7 +353,7 @@ class TUI(urwid.Frame):
|
||||||
)
|
)
|
||||||
|
|
||||||
def clear_screen(self):
|
def clear_screen(self):
|
||||||
self.loop.screen.clear()
|
self.screen.clear()
|
||||||
|
|
||||||
def show_links(self, status):
|
def show_links(self, status):
|
||||||
links = parse_content_links(status.original.data["content"]) if status else []
|
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, "close", _close)
|
||||||
urwid.connect_signal(widget, "delete", _delete)
|
urwid.connect_signal(widget, "delete", _delete)
|
||||||
self.open_overlay(widget, title="Delete status?", options=dict(
|
self.open_overlay(widget, title="Delete status?", options=dict(
|
||||||
align="center", width=("relative", 60),
|
align="center", width=30,
|
||||||
valign="middle", height=5,
|
valign="middle", height=4,
|
||||||
))
|
))
|
||||||
|
|
||||||
def post_status(self, content, warning, visibility, in_reply_to_id):
|
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 async_toggle_favourite(self, timeline, status):
|
||||||
def _favourite():
|
def _favourite():
|
||||||
logger.info("Favouriting {}".format(status))
|
|
||||||
api.favourite(self.app, self.user, status.id)
|
api.favourite(self.app, self.user, status.id)
|
||||||
|
|
||||||
def _unfavourite():
|
def _unfavourite():
|
||||||
logger.info("Unfavouriting {}".format(status))
|
|
||||||
api.unfavourite(self.app, self.user, status.id)
|
api.unfavourite(self.app, self.user, status.id)
|
||||||
|
|
||||||
def _done(loop):
|
def _done(loop):
|
||||||
|
@ -539,11 +561,9 @@ class TUI(urwid.Frame):
|
||||||
|
|
||||||
def async_toggle_reblog(self, timeline, status):
|
def async_toggle_reblog(self, timeline, status):
|
||||||
def _reblog():
|
def _reblog():
|
||||||
logger.info("Reblogging {}".format(status))
|
|
||||||
api.reblog(self.app, self.user, status.original.id, visibility=get_default_visibility())
|
api.reblog(self.app, self.user, status.original.id, visibility=get_default_visibility())
|
||||||
|
|
||||||
def _unreblog():
|
def _unreblog():
|
||||||
logger.info("Unreblogging {}".format(status))
|
|
||||||
api.unreblog(self.app, self.user, status.original.id)
|
api.unreblog(self.app, self.user, status.original.id)
|
||||||
|
|
||||||
def _done(loop):
|
def _done(loop):
|
||||||
|
@ -567,7 +587,6 @@ class TUI(urwid.Frame):
|
||||||
|
|
||||||
def async_translate(self, timeline, status):
|
def async_translate(self, timeline, status):
|
||||||
def _translate():
|
def _translate():
|
||||||
logger.info("Translating {}".format(status))
|
|
||||||
self.footer.set_message("Translating status {}".format(status.original.id))
|
self.footer.set_message("Translating status {}".format(status.original.id))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
@ -600,11 +619,9 @@ class TUI(urwid.Frame):
|
||||||
|
|
||||||
def async_toggle_bookmark(self, timeline, status):
|
def async_toggle_bookmark(self, timeline, status):
|
||||||
def _bookmark():
|
def _bookmark():
|
||||||
logger.info("Bookmarking {}".format(status))
|
|
||||||
api.bookmark(self.app, self.user, status.id)
|
api.bookmark(self.app, self.user, status.id)
|
||||||
|
|
||||||
def _unbookmark():
|
def _unbookmark():
|
||||||
logger.info("Unbookmarking {}".format(status))
|
|
||||||
api.unbookmark(self.app, self.user, status.id)
|
api.unbookmark(self.app, self.user, status.id)
|
||||||
|
|
||||||
def _done(loop):
|
def _done(loop):
|
||||||
|
@ -705,7 +722,7 @@ class TUI(urwid.Frame):
|
||||||
if not self.overlay:
|
if not self.overlay:
|
||||||
self.show_goto_menu()
|
self.show_goto_menu()
|
||||||
|
|
||||||
elif key in ('h', 'H'):
|
elif key == '?':
|
||||||
if not self.overlay:
|
if not self.overlay:
|
||||||
self.show_help()
|
self.show_help()
|
||||||
|
|
||||||
|
|
|
@ -66,8 +66,8 @@ class StatusComposer(urwid.Frame):
|
||||||
|
|
||||||
def generate_list_items(self):
|
def generate_list_items(self):
|
||||||
if self.in_reply_to:
|
if self.in_reply_to:
|
||||||
yield urwid.Text(("gray", "Replying to {}".format(self.in_reply_to.original.account)))
|
yield urwid.Text(("dim", "Replying to {}".format(self.in_reply_to.original.account)))
|
||||||
yield urwid.AttrWrap(urwid.Divider("-"), "gray")
|
yield urwid.AttrWrap(urwid.Divider("-"), "dim")
|
||||||
|
|
||||||
yield urwid.Text("Status message")
|
yield urwid.Text("Status message")
|
||||||
yield self.content_edit
|
yield self.content_edit
|
||||||
|
|
|
@ -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 = [
|
PALETTE = [
|
||||||
# Components
|
# Components
|
||||||
('button', 'white', 'black'),
|
('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'),
|
('columns_divider', 'white', 'dark blue'),
|
||||||
('content_warning', 'white', 'dark magenta'),
|
('content_warning', 'white', 'dark magenta'),
|
||||||
('editbox', 'white', 'black'),
|
('editbox', 'white', 'black'),
|
||||||
|
@ -12,54 +25,61 @@ PALETTE = [
|
||||||
('footer_status', 'white', 'dark blue'),
|
('footer_status', 'white', 'dark blue'),
|
||||||
('footer_status_bold', 'white, bold', 'dark blue'),
|
('footer_status_bold', 'white, bold', 'dark blue'),
|
||||||
('header', 'white', 'dark blue'),
|
('header', 'white', 'dark blue'),
|
||||||
('header_bold', 'white,bold', 'dark blue'),
|
('header_bold', 'white,bold', 'dark blue', 'bold'),
|
||||||
('intro_bigtext', 'yellow', ''),
|
('intro_bigtext', 'yellow', ''),
|
||||||
('intro_smalltext', 'light blue', ''),
|
('intro_smalltext', 'light blue', ''),
|
||||||
('poll_bar', 'white', 'dark 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
|
# Functional
|
||||||
('hashtag', 'light cyan,bold', ''),
|
('account', 'dark green', ''),
|
||||||
('followed_hashtag', 'yellow,bold', ''),
|
('hashtag', 'light cyan,bold', '', 'bold'),
|
||||||
('link', ',italics', ''),
|
('hashtag_followed', 'yellow,bold', '', 'bold'),
|
||||||
('link_focused', ',italics', 'dark magenta'),
|
('link', ',italics', '', ',italics'),
|
||||||
|
('link_focused', ',italics', 'dark magenta', "underline,italics"),
|
||||||
# Colors
|
('shortcut', 'light blue', ''),
|
||||||
('bold', ',bold', ''),
|
('shortcut_highlight', 'white,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', ''),
|
|
||||||
('warning', 'light red', ''),
|
('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
|
# HTML tag styling
|
||||||
('a', ',italics', ''),
|
('a', ',italics', '', 'italics'),
|
||||||
# em tag is mapped to i
|
# em tag is mapped to i
|
||||||
('i', ',italics', ''),
|
('i', ',italics', '', 'italics'),
|
||||||
# strong tag is mapped to b
|
# strong tag is mapped to b
|
||||||
('b', ',bold', ''),
|
('b', ',bold', '', 'bold'),
|
||||||
# special case for bold + italic nested tags
|
# special case for bold + italic nested tags
|
||||||
('bi', ',bold,italics', ''),
|
('bi', ',bold,italics', '', ',bold,italics'),
|
||||||
('u', ',underline', ''),
|
('u', ',underline', '', ',underline'),
|
||||||
('del', ',strikethrough', ''),
|
('del', ',strikethrough', '', ',strikethrough'),
|
||||||
('code', 'light gray, standout', ''),
|
('code', 'light gray, standout', '', ',standout'),
|
||||||
('pre', 'light gray, standout', ''),
|
('pre', 'light gray, standout', '', ',standout'),
|
||||||
('blockquote', 'light gray', ''),
|
('blockquote', 'light gray', '', ''),
|
||||||
('h1', ',bold', ''),
|
('h1', ',bold', '', ',bold'),
|
||||||
('h2', ',bold', ''),
|
('h2', ',bold', '', ',bold'),
|
||||||
('h3', ',bold', ''),
|
('h3', ',bold', '', ',bold'),
|
||||||
('h4', ',bold', ''),
|
('h4', ',bold', '', ',bold'),
|
||||||
('h5', ',bold', ''),
|
('h5', ',bold', '', ',bold'),
|
||||||
('h6', ',bold', ''),
|
('h6', ',bold', '', ',bold'),
|
||||||
('class_mention_hashtag', 'light cyan,bold', ''),
|
('class_mention_hashtag', 'light cyan,bold', '', ',bold'),
|
||||||
('class_hashtag', 'light cyan,bold', ''),
|
('class_hashtag', 'light cyan,bold', '', ',bold'),
|
||||||
|
|
||||||
]
|
]
|
||||||
|
|
||||||
VISIBILITY_OPTIONS = [
|
VISIBILITY_OPTIONS = [
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
|
|
||||||
from .utils import parse_datetime
|
from toot.utils.datetime import parse_datetime
|
||||||
|
|
||||||
Author = namedtuple("Author", ["account", "display_name", "username"])
|
Author = namedtuple("Author", ["account", "display_name", "username"])
|
||||||
|
|
||||||
|
|
|
@ -81,15 +81,15 @@ class StatusDeleteConfirmation(urwid.ListBox):
|
||||||
signals = ["delete", "close"]
|
signals = ["delete", "close"]
|
||||||
|
|
||||||
def __init__(self, status):
|
def __init__(self, status):
|
||||||
yes = SelectableText("Yes, send it to heck")
|
def _delete(_):
|
||||||
no = SelectableText("No, I'll spare it for now")
|
self._emit("delete")
|
||||||
|
|
||||||
urwid.connect_signal(yes, "click", lambda *args: self._emit("delete"))
|
def _close(_):
|
||||||
urwid.connect_signal(no, "click", lambda *args: self._emit("close"))
|
self._emit("close")
|
||||||
|
|
||||||
walker = urwid.SimpleFocusListWalker([
|
walker = urwid.SimpleFocusListWalker([
|
||||||
urwid.AttrWrap(yes, "", "blue_selected"),
|
Button("Yes, delete", on_press=_delete),
|
||||||
urwid.AttrWrap(no, "", "blue_selected"),
|
Button("No, cancel", on_press=_close),
|
||||||
])
|
])
|
||||||
super().__init__(walker)
|
super().__init__(walker)
|
||||||
|
|
||||||
|
@ -196,9 +196,9 @@ class Help(urwid.Padding):
|
||||||
|
|
||||||
def generate_contents(self):
|
def generate_contents(self):
|
||||||
def h(text):
|
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.Divider()
|
||||||
yield urwid.Text(("bold", "General usage"))
|
yield urwid.Text(("bold", "General usage"))
|
||||||
yield urwid.Divider()
|
yield urwid.Divider()
|
||||||
|
@ -211,9 +211,9 @@ class Help(urwid.Padding):
|
||||||
yield urwid.Divider()
|
yield urwid.Divider()
|
||||||
yield urwid.Text(h(" [Q] - quit toot"))
|
yield urwid.Text(h(" [Q] - quit toot"))
|
||||||
yield urwid.Text(h(" [G] - go to - switch timelines"))
|
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(" [,] - refresh current timeline"))
|
||||||
yield urwid.Text(h(" [H] - show this help"))
|
yield urwid.Text(h(" [?] - show this help"))
|
||||||
yield urwid.Divider()
|
yield urwid.Divider()
|
||||||
yield urwid.Text(("bold", "Status keys"))
|
yield urwid.Text(("bold", "Status keys"))
|
||||||
yield urwid.Divider()
|
yield urwid.Divider()
|
||||||
|
@ -262,10 +262,10 @@ class Account(urwid.ListBox):
|
||||||
yield Button("Cancel", on_press=cancel_action, user_data=self)
|
yield Button("Cancel", on_press=cancel_action, user_data=self)
|
||||||
else:
|
else:
|
||||||
if self.user.username == account["acct"]:
|
if self.user.username == account["acct"]:
|
||||||
yield urwid.Text(("light gray", "This is your account"))
|
yield urwid.Text(("dim", "This is your account"))
|
||||||
else:
|
else:
|
||||||
if relationship['requested']:
|
if relationship['requested']:
|
||||||
yield urwid.Text(("light gray", "< Follow request is pending >"))
|
yield urwid.Text(("dim", "< Follow request is pending >"))
|
||||||
else:
|
else:
|
||||||
yield Button("Unfollow" if relationship['following'] else "Follow",
|
yield Button("Unfollow" if relationship['following'] else "Follow",
|
||||||
on_press=confirm_action, user_data=self)
|
on_press=confirm_action, user_data=self)
|
||||||
|
@ -277,7 +277,7 @@ class Account(urwid.ListBox):
|
||||||
|
|
||||||
yield urwid.Divider("─")
|
yield urwid.Divider("─")
|
||||||
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"]:
|
if account["note"]:
|
||||||
yield urwid.Divider()
|
yield urwid.Divider()
|
||||||
|
@ -287,12 +287,12 @@ class Account(urwid.ListBox):
|
||||||
yield (line)
|
yield (line)
|
||||||
|
|
||||||
yield urwid.Divider()
|
yield urwid.Divider()
|
||||||
yield urwid.Text(["ID: ", ("green", f"{account['id']}")])
|
yield urwid.Text(["ID: ", ("highlight", f"{account['id']}")])
|
||||||
yield urwid.Text(["Since: ", ("green", f"{account['created_at'][:10]}")])
|
yield urwid.Text(["Since: ", ("highlight", f"{account['created_at'][:10]}")])
|
||||||
yield urwid.Divider()
|
yield urwid.Divider()
|
||||||
|
|
||||||
if account["bot"]:
|
if account["bot"]:
|
||||||
yield urwid.Text([("green", "Bot \N{robot face}")])
|
yield urwid.Text([("highlight", "Bot \N{robot face}")])
|
||||||
yield urwid.Divider()
|
yield urwid.Divider()
|
||||||
if account["locked"]:
|
if account["locked"]:
|
||||||
yield urwid.Text([("warning", "Locked \N{lock}")])
|
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.Text([("warning", "Suspended \N{cross mark}")])
|
||||||
yield urwid.Divider()
|
yield urwid.Divider()
|
||||||
if relationship["followed_by"]:
|
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()
|
yield urwid.Divider()
|
||||||
if relationship["blocked_by"]:
|
if relationship["blocked_by"]:
|
||||||
yield urwid.Text(("warning", "Blocks you \N{no entry}"))
|
yield urwid.Text(("warning", "Blocks you \N{no entry}"))
|
||||||
yield urwid.Divider()
|
yield urwid.Divider()
|
||||||
|
|
||||||
yield urwid.Text(["Followers: ", ("yellow", f"{account['followers_count']}")])
|
yield urwid.Text(["Followers: ", ("highlight", f"{account['followers_count']}")])
|
||||||
yield urwid.Text(["Following: ", ("yellow", f"{account['following_count']}")])
|
yield urwid.Text(["Following: ", ("highlight", f"{account['following_count']}")])
|
||||||
yield urwid.Text(["Statuses: ", ("yellow", f"{account['statuses_count']}")])
|
yield urwid.Text(["Statuses: ", ("highlight", f"{account['statuses_count']}")])
|
||||||
|
|
||||||
if account["fields"]:
|
if account["fields"]:
|
||||||
for field in account["fields"]:
|
for field in account["fields"]:
|
||||||
name = field["name"].title()
|
name = field["name"].title()
|
||||||
yield urwid.Divider()
|
yield urwid.Divider()
|
||||||
yield urwid.Text([("yellow", f"{name.rstrip(':')}"), ":"])
|
yield urwid.Text([("bold", f"{name.rstrip(':')}"), ":"])
|
||||||
|
|
||||||
widgetlist = parser.html_to_widgets(field["value"])
|
widgetlist = parser.html_to_widgets(field["value"])
|
||||||
for line in widgetlist:
|
for line in widgetlist:
|
||||||
yield (line)
|
yield (line)
|
||||||
|
|
||||||
if field["verified_at"]:
|
if field["verified_at"]:
|
||||||
yield urwid.Text(("green", "✓ Verified"))
|
yield urwid.Text(("success", "✓ Verified"))
|
||||||
|
|
||||||
yield urwid.Divider()
|
yield urwid.Divider()
|
||||||
yield link("", account["url"])
|
yield link("", account["url"])
|
||||||
|
|
|
@ -2,7 +2,7 @@ import urwid
|
||||||
|
|
||||||
from toot import api
|
from toot import api
|
||||||
from toot.exceptions import ApiError
|
from toot.exceptions import ApiError
|
||||||
from .utils import parse_datetime
|
from toot.utils.datetime import parse_datetime
|
||||||
from .widgets import Button, CheckBox, RadioButton
|
from .widgets import Button, CheckBox, RadioButton
|
||||||
from .richtext import ContentParser
|
from .richtext import ContentParser
|
||||||
|
|
||||||
|
@ -59,7 +59,7 @@ class Poll(urwid.ListBox):
|
||||||
|
|
||||||
if poll["voted"] or poll["expired"]:
|
if poll["voted"] or poll["expired"]:
|
||||||
prefix = " ✓ " if voted_for else " "
|
prefix = " ✓ " if voted_for else " "
|
||||||
yield urwid.Text(("gray", prefix + f'{option["title"]}'))
|
yield urwid.Text(("dim", prefix + f'{option["title"]}'))
|
||||||
else:
|
else:
|
||||||
if poll["multiple"]:
|
if poll["multiple"]:
|
||||||
checkbox = CheckBox(f'{option["title"]}')
|
checkbox = CheckBox(f'{option["title"]}')
|
||||||
|
@ -81,7 +81,7 @@ class Poll(urwid.ListBox):
|
||||||
)
|
)
|
||||||
poll_detail += " · Closes on {}".format(expires_at)
|
poll_detail += " · Closes on {}".format(expires_at)
|
||||||
|
|
||||||
yield urwid.Text(("gray", poll_detail))
|
yield urwid.Text(("dim", poll_detail))
|
||||||
|
|
||||||
def generate_contents(self, status):
|
def generate_contents(self, status):
|
||||||
yield urwid.Divider()
|
yield urwid.Divider()
|
||||||
|
|
|
@ -6,16 +6,17 @@ import webbrowser
|
||||||
|
|
||||||
from typing import List, Optional
|
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 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.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 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")
|
logger = logging.getLogger("toot")
|
||||||
|
|
||||||
|
@ -55,7 +56,7 @@ class Timeline(urwid.Columns):
|
||||||
|
|
||||||
super().__init__([
|
super().__init__([
|
||||||
("weight", 40, self.status_list),
|
("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),
|
("weight", 60, status_widget),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
@ -82,16 +83,15 @@ class Timeline(urwid.Columns):
|
||||||
return urwid.ListBox(walker)
|
return urwid.ListBox(walker)
|
||||||
|
|
||||||
def build_list_item(self, status):
|
def build_list_item(self, status):
|
||||||
item = StatusListItem(status)
|
item = StatusListItem(status, self.tui.args.relative_datetimes)
|
||||||
urwid.connect_signal(item, "click", lambda *args:
|
urwid.connect_signal(item, "click", lambda *args:
|
||||||
self.tui.show_context_menu(status))
|
self.tui.show_context_menu(status))
|
||||||
return urwid.AttrMap(item, None, focus_map={
|
return urwid.AttrMap(item, None, focus_map={
|
||||||
"blue": "green_selected",
|
"status_list_account": "status_list_selected",
|
||||||
"green": "green_selected",
|
"status_list_timestamp": "status_list_selected",
|
||||||
"yellow": "green_selected",
|
"highligh": "status_list_selected",
|
||||||
"cyan": "green_selected",
|
"dim": "status_list_selected",
|
||||||
"red": "green_selected",
|
None: "status_list_selected",
|
||||||
None: "green_selected",
|
|
||||||
})
|
})
|
||||||
|
|
||||||
def get_option_text(self, status: Optional[Status]) -> Optional[urwid.Text]:
|
def get_option_text(self, status: Optional[Status]) -> Optional[urwid.Text]:
|
||||||
|
@ -108,17 +108,17 @@ class Timeline(urwid.Columns):
|
||||||
"[F]avourite",
|
"[F]avourite",
|
||||||
"[V]iew",
|
"[V]iew",
|
||||||
"[T]hread" if not self.is_thread else "",
|
"[T]hread" if not self.is_thread else "",
|
||||||
"[L]inks",
|
"L[i]nks",
|
||||||
"[R]eply",
|
"[R]eply",
|
||||||
"[P]oll" if poll and not poll["expired"] else "",
|
"[P]oll" if poll and not poll["expired"] else "",
|
||||||
"So[u]rce",
|
"So[u]rce",
|
||||||
"[Z]oom",
|
"[Z]oom",
|
||||||
"Tra[n]slate" if self.tui.can_translate else "",
|
"Tra[n]slate" if self.tui.can_translate else "",
|
||||||
"Cop[y]",
|
"Cop[y]",
|
||||||
"[H]elp",
|
"Help([?])",
|
||||||
]
|
]
|
||||||
options = "\n" + " ".join(o for o in options if o)
|
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)
|
return urwid.Text(options)
|
||||||
|
|
||||||
def get_focused_status(self):
|
def get_focused_status(self):
|
||||||
|
@ -220,7 +220,7 @@ class Timeline(urwid.Columns):
|
||||||
self.tui.async_toggle_bookmark(self, status)
|
self.tui.async_toggle_bookmark(self, status)
|
||||||
return
|
return
|
||||||
|
|
||||||
if key in ("l", "L"):
|
if key in ("i", "I"):
|
||||||
self.tui.show_links(status)
|
self.tui.show_links(status)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@ -338,13 +338,13 @@ class StatusDetails(urwid.Pile):
|
||||||
def content_generator(self, status, reblogged_by):
|
def content_generator(self, status, reblogged_by):
|
||||||
if reblogged_by:
|
if reblogged_by:
|
||||||
text = "♺ {} boosted".format(reblogged_by.display_name or reblogged_by.username)
|
text = "♺ {} boosted".format(reblogged_by.display_name or reblogged_by.username)
|
||||||
yield ("pack", urwid.Text(("gray", text)))
|
yield ("pack", urwid.Text(("dim", text)))
|
||||||
yield ("pack", urwid.AttrMap(urwid.Divider("-"), "gray"))
|
yield ("pack", urwid.AttrMap(urwid.Divider("-"), "dim"))
|
||||||
|
|
||||||
if status.author.display_name:
|
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.Text((account_color, status.author.account)))
|
||||||
yield ("pack", urwid.Divider())
|
yield ("pack", urwid.Divider())
|
||||||
|
|
||||||
|
@ -367,7 +367,7 @@ class StatusDetails(urwid.Pile):
|
||||||
media = status.data["media_attachments"]
|
media = status.data["media_attachments"]
|
||||||
if media:
|
if media:
|
||||||
for m in 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"], ")"]))
|
yield ("pack", urwid.Text([("bold", "Media attachment"), " (", m["type"], ")"]))
|
||||||
if m["description"]:
|
if m["description"]:
|
||||||
yield ("pack", urwid.Text(m["description"]))
|
yield ("pack", urwid.Text(m["description"]))
|
||||||
|
@ -386,7 +386,7 @@ class StatusDetails(urwid.Pile):
|
||||||
application = status.data.get("application") or {}
|
application = status.data.get("application") or {}
|
||||||
application = application.get("name")
|
application = application.get("name")
|
||||||
|
|
||||||
yield ("pack", urwid.AttrWrap(urwid.Divider("-"), "gray"))
|
yield ("pack", urwid.AttrWrap(urwid.Divider("-"), "dim"))
|
||||||
|
|
||||||
translated_from = (
|
translated_from = (
|
||||||
language_name(status.original.translated_from)
|
language_name(status.original.translated_from)
|
||||||
|
@ -395,24 +395,24 @@ class StatusDetails(urwid.Pile):
|
||||||
)
|
)
|
||||||
|
|
||||||
visibility_colors = {
|
visibility_colors = {
|
||||||
"public": "gray",
|
"public": "visibility_public",
|
||||||
"unlisted": "white",
|
"unlisted": "visibility_unlisted",
|
||||||
"private": "cyan",
|
"private": "visibility_private",
|
||||||
"direct": "yellow"
|
"direct": "visibility_direct"
|
||||||
}
|
}
|
||||||
|
|
||||||
visibility = status.visibility.title()
|
visibility = status.visibility.title()
|
||||||
visibility_color = visibility_colors.get(status.visibility, "gray")
|
visibility_color = visibility_colors.get(status.visibility, "dim")
|
||||||
|
|
||||||
yield ("pack", urwid.Text([
|
yield ("pack", urwid.Text([
|
||||||
("blue", f"{status.created_at.strftime('%Y-%m-%d %H:%M')} "),
|
("status_detail_timestamp", f"{status.created_at.strftime('%Y-%m-%d %H:%M')} "),
|
||||||
("red" if status.bookmarked else "gray", "b "),
|
("status_detail_bookmarked" if status.bookmarked else "dim", "b "),
|
||||||
("gray", f"⤶ {status.data['replies_count']} "),
|
("dim", f"⤶ {status.data['replies_count']} "),
|
||||||
("yellow" if status.reblogged else "gray", f"♺ {status.data['reblogs_count']} "),
|
("highlight" if status.reblogged else "dim", f"♺ {status.data['reblogs_count']} "),
|
||||||
("yellow" if status.favourited else "gray", f"★ {status.data['favourites_count']}"),
|
("highlight" if status.favourited else "dim", f"★ {status.data['favourites_count']}"),
|
||||||
(visibility_color, f" · {visibility}"),
|
(visibility_color, f" · {visibility}"),
|
||||||
("yellow", f" · Translated from {translated_from} " if translated_from else ""),
|
("highlight", f" · Translated from {translated_from} " if translated_from else ""),
|
||||||
("gray", f" · {application}" if application else ""),
|
("dim", f" · {application}" if application else ""),
|
||||||
]))
|
]))
|
||||||
|
|
||||||
# Push things to bottom
|
# Push things to bottom
|
||||||
|
@ -424,9 +424,9 @@ class StatusDetails(urwid.Pile):
|
||||||
return urwid.LineBox(contents)
|
return urwid.LineBox(contents)
|
||||||
|
|
||||||
def card_generator(self, card):
|
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"):
|
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("")
|
yield urwid.Text("")
|
||||||
if card["description"]:
|
if card["description"]:
|
||||||
yield urwid.Text(card["description"].strip())
|
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")
|
expires_at = parse_datetime(poll["expires_at"]).strftime("%Y-%m-%d %H:%M")
|
||||||
status += " · Closes on {}".format(expires_at)
|
status += " · Closes on {}".format(expires_at)
|
||||||
|
|
||||||
yield urwid.Text(("gray", status))
|
yield urwid.Text(("dim", status))
|
||||||
|
|
||||||
|
|
||||||
class StatusListItem(SelectableColumns):
|
class StatusListItem(SelectableColumns):
|
||||||
def __init__(self, status):
|
def __init__(self, status, relative_datetimes):
|
||||||
edited_at = status.data.get("edited_at")
|
edited_at = status.data.get("edited_at")
|
||||||
|
|
||||||
# TODO: hacky implementation to avoid creating conflicts for existing
|
# TODO: hacky implementation to avoid creating conflicts for existing
|
||||||
# pull reuqests, refactor when merged.
|
# pull reuqests, refactor when merged.
|
||||||
created_at = (
|
created_at = (
|
||||||
time_ago(status.created_at).ljust(3, " ")
|
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")
|
else status.created_at.strftime("%Y-%m-%d %H:%M")
|
||||||
)
|
)
|
||||||
|
|
||||||
edited_flag = "*" if edited_at else " "
|
edited_flag = "*" if edited_at else " "
|
||||||
favourited = ("yellow", "★") if status.original.favourited else " "
|
favourited = ("highlight", "★") if status.original.favourited else " "
|
||||||
reblogged = ("yellow", "♺") if status.original.reblogged else " "
|
reblogged = ("highlight", "♺") if status.original.reblogged else " "
|
||||||
is_reblog = ("cyan", "♺") if status.reblog else " "
|
is_reblog = ("dim", "♺") if status.reblog else " "
|
||||||
is_reply = ("cyan", "⤶") if status.original.in_reply_to else " "
|
is_reply = ("dim", "⤶") if status.original.in_reply_to else " "
|
||||||
|
|
||||||
return super().__init__([
|
return super().__init__([
|
||||||
("pack", SelectableText(("blue", created_at), wrap="clip")),
|
("pack", SelectableText(("status_list_timestamp", created_at), wrap="clip")),
|
||||||
("pack", urwid.Text(("blue", edited_flag))),
|
("pack", urwid.Text(("status_list_timestamp", edited_flag))),
|
||||||
("pack", urwid.Text(" ")),
|
("pack", urwid.Text(" ")),
|
||||||
("pack", urwid.Text(favourited)),
|
("pack", urwid.Text(favourited)),
|
||||||
("pack", urwid.Text(" ")),
|
("pack", urwid.Text(" ")),
|
||||||
("pack", urwid.Text(reblogged)),
|
("pack", urwid.Text(reblogged)),
|
||||||
("pack", urwid.Text(" ")),
|
("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_reply)),
|
||||||
("pack", urwid.Text(is_reblog)),
|
("pack", urwid.Text(is_reblog)),
|
||||||
("pack", urwid.Text(" ")),
|
("pack", urwid.Text(" ")),
|
||||||
|
|
|
@ -1,60 +1,14 @@
|
||||||
import base64
|
import base64
|
||||||
import urwid
|
|
||||||
from html.parser import HTMLParser
|
|
||||||
import math
|
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
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')
|
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=""):
|
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.write(f"\033]52;c;{b64_text}\a")
|
||||||
screen.flush()
|
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
|
||||||
|
)
|
||||||
|
|
|
@ -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)
|
|
@ -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"
|
Loading…
Reference in New Issue