1
0
mirror of https://github.com/ihabunek/toot.git synced 2024-11-03 04:17:21 -05:00

Merge pull request #428 from ihabunek/click

Migrate to Click
This commit is contained in:
Ivan Habunek 2023-12-14 12:07:29 +01:00 committed by GitHub
commit 3399c8763d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
51 changed files with 3584 additions and 3070 deletions

10
.gitignore vendored
View File

@ -6,12 +6,14 @@
/.env
/.envrc
/.pytest_cache/
/book
/build/
/bundle/
/dist/
/htmlcov/
/tmp/
/toot-*.tar.gz
debug.log
/pyrightconfig.json
/tmp/
/toot-*.pyz
/toot-*.tar.gz
/venv/
/book
debug.log

View File

@ -3,6 +3,34 @@ Changelog
<!-- Do not edit. This file is automatically generated from changelog.yaml.-->
**0.40.0 (TBA)**
This release includes a major rewrite to use
[Click](https://click.palletsprojects.com/) for creating the command line
interface. This allows for some new features like nested commands, setting
parameters via environment variables, and shell completion. See docs for
details. Backward compatibility should be mostly preserved, except for cases
noted below please report any issues.
* BREAKING: Remove deprecated `--disable-https` option for `login` and
`login_cli`, pass the base URL instead
* BREAKING: Options `--debug`, `--color`, `--quiet` must be specified after
`toot` but before the command
* Enable passing params via environment variables, see:
https://toot.bezdomni.net/environment_variables.html
* Add shell completion, see: https://toot.bezdomni.net/shell_completion.html
* Add `--json` option to tag commands
* Add `tags info`, `tags featured`, `tags feature`, and `tags unfeature`
commands
* Add `tags followed`, `tags follow`, and `tags unfollow` sub-commands,
deprecate `tags_followed`, `tags_follow`, and `tags tags_unfollow`
* Add `lists accounts`, `lists add`, `lists create`, `lists delete`, `lists
list`, `lists remove` subcommands, deprecate `lists`, `lists_accounts`,
`lists_add`, `lists_create`, `lists_delete`, `lists_remove` commands.
* Add `toot --width` option for setting your prefered terminal width
* Add `--media-viewer` and `--colors` options to `toot tui`. These were
previously accessible only via settings.
**0.39.0 (2023-11-23)**
* Add `--json` option to many commands, this makes them print the JSON data

View File

@ -15,12 +15,12 @@ test:
coverage:
coverage erase
coverage run
coverage html
coverage html --omit "toot/tui/*"
coverage report
clean :
find . -name "*pyc" | xargs rm -rf $1
rm -rf build dist MANIFEST htmlcov toot*.tar.gz
rm -rf build dist MANIFEST htmlcov bundle toot*.tar.gz toot*.pyz
changelog:
./scripts/generate_changelog > CHANGELOG.md
@ -30,7 +30,19 @@ docs: changelog
mdbook build
docs-serve:
mdbook serve
mdbook serve --port 8000
docs-deploy: docs
rsync --archive --compress --delete --stats book/ bezdomni:web/toot
bundle:
mkdir bundle
cp toot/__main__.py bundle
pip install . --target=bundle
rm -rf bundle/*.dist-info
find bundle/ -type d -name "__pycache__" -exec rm -rf {} +
python -m zipapp \
--python "/usr/bin/env python3" \
--output toot-`git describe`.pyz bundle \
--compress
echo "Bundle created: toot-`git describe`.pyz"

View File

@ -1,3 +1,23 @@
0.40.0:
date: TBA
description: |
This release includes a major rewrite to use [Click](https://click.palletsprojects.com/) for
creating the command line interface. This allows for some new features like nested commands,
setting parameters via environment variables, and shell completion. See docs for details.
Backward compatibility should be mostly preserved, except for cases noted below please report
any issues.
changes:
- "BREAKING: Remove deprecated `--disable-https` option for `login` and `login_cli`, pass the base URL instead"
- "BREAKING: Options `--debug`, `--color`, `--quiet` must be specified after `toot` but before the command"
- "Enable passing params via environment variables, see: https://toot.bezdomni.net/environment_variables.html"
- "Add shell completion, see: https://toot.bezdomni.net/shell_completion.html"
- "Add `--json` option to tag commands"
- "Add `tags info`, `tags featured`, `tags feature`, and `tags unfeature` commands"
- "Add `tags followed`, `tags follow`, and `tags unfollow` sub-commands, deprecate `tags_followed`, `tags_follow`, and `tags tags_unfollow`"
- "Add `lists accounts`, `lists add`, `lists create`, `lists delete`, `lists list`, `lists remove` subcommands, deprecate `lists`, `lists_accounts`, `lists_add`, `lists_create`, `lists_delete`, `lists_remove` commands."
- "Add `toot --width` option for setting your prefered terminal width"
- "Add `--media-viewer` and `--colors` options to `toot tui`. These were previously accessible only via settings."
0.39.0:
date: 2023-11-23
changes:

View File

@ -6,6 +6,8 @@
- [Usage](usage.md)
- [Advanced](advanced.md)
- [Settings](settings.md)
- [Shell completion](shell_completion.md)
- [Environment variables](environment_variables.md)
- [TUI](tui.md)
- [Contributing](contributing.md)
- [Documentation](documentation.md)

View File

@ -3,6 +3,34 @@ Changelog
<!-- Do not edit. This file is automatically generated from changelog.yaml.-->
**0.40.0 (TBA)**
This release includes a major rewrite to use
[Click](https://click.palletsprojects.com/) for creating the command line
interface. This allows for some new features like nested commands, setting
parameters via environment variables, and shell completion. See docs for
details. Backward compatibility should be mostly preserved, except for cases
noted below please report any issues.
* BREAKING: Remove deprecated `--disable-https` option for `login` and
`login_cli`, pass the base URL instead
* BREAKING: Options `--debug`, `--color`, `--quiet` must be specified after
`toot` but before the command
* Enable passing params via environment variables, see:
https://toot.bezdomni.net/environment_variables.html
* Add shell completion, see: https://toot.bezdomni.net/shell_completion.html
* Add `--json` option to tag commands
* Add `tags info`, `tags featured`, `tags feature`, and `tags unfeature`
commands
* Add `tags followed`, `tags follow`, and `tags unfollow` sub-commands,
deprecate `tags_followed`, `tags_follow`, and `tags tags_unfollow`
* Add `lists accounts`, `lists add`, `lists create`, `lists delete`, `lists
list`, `lists remove` subcommands, deprecate `lists`, `lists_accounts`,
`lists_add`, `lists_create`, `lists_delete`, `lists_remove` commands.
* Add `toot --width` option for setting your prefered terminal width
* Add `--media-viewer` and `--colors` options to `toot tui`. These were
previously accessible only via settings.
**0.39.0 (2023-11-23)**
* Add `--json` option to many commands, this makes them print the JSON data

View File

@ -0,0 +1,19 @@
# Environment variables
> Introduced in toot v0.40.0
Toot allows setting defaults for parameters via environment variables.
Environment variables should be named `TOOT_<COMMAND_NAME>_<OPTION_NAME>`.
### Examples
Command with option | Environment variable
------------------- | --------------------
`toot --color` | `TOOT_COLOR=true`
`toot --no-color` | `TOOT_COLOR=false`
`toot post --editor vim` | `TOOT_POST_EDITOR=vim`
`toot post --visibility unlisted` | `TOOT_POST_VISIBILITY=unlisted`
`toot tui --media-viewer feh` | `TOOT_TUI_MEDIA_VIEWER=feh`
Note that these can also be set via the [settings file](./settings.html).

31
docs/shell_completion.md Normal file
View File

@ -0,0 +1,31 @@
# Shell completion
> Introduced in toot 0.40.0
Toot uses [Click shell completion](https://click.palletsprojects.com/en/8.1.x/shell-completion/) which works on Bash, Fish and Zsh.
To enable completion, toot must be [installed](./installation.html) as a command and available by ivoking `toot`. Then follow the instructions for your shell.
**Bash**
Add to `~/.bashrc`:
```
eval "$(_TOOT_COMPLETE=bash_source toot)"
```
**Fish**
Add to `~/.config/fish/completions/toot.fish`:
```
_TOOT_COMPLETE=fish_source toot | source
```
**Zsh**
Add to `~/.zshrc`:
```
eval "$(_TOOT_COMPLETE=zsh_source toot)"
```

2
pytest.ini Normal file
View File

@ -0,0 +1,2 @@
[pytest]
testpaths = tests

View File

@ -21,6 +21,13 @@ for version in data.keys():
changes = data[version]["changes"]
print(f"**{version} ({date})**")
print()
if "description" in data[version]:
description = data[version]["description"].strip()
for line in textwrap.wrap(description, 80):
print(line)
print()
for c in changes:
lines = textwrap.wrap(c, 78)
initial = True

View File

@ -12,7 +12,7 @@ and blocking accounts and other actions.
setup(
name='toot',
version='0.39.0',
version='0.40.0',
description='Mastodon CLI client',
long_description=long_description.strip(),
author='Ivan Habunek',
@ -31,9 +31,10 @@ setup(
'License :: OSI Approved :: GNU General Public License v3 (GPLv3)',
'Programming Language :: Python :: 3',
],
packages=['toot', 'toot.tui', 'toot.tui.richtext', 'toot.utils'],
packages=['toot', 'toot.cli', 'toot.tui', 'toot.tui.richtext', 'toot.utils'],
python_requires=">=3.7",
install_requires=[
"click~=8.1",
"requests>=2.13,<3.0",
"beautifulsoup4>=4.5.0,<5.0",
"wcwidth>=0.1.7",
@ -58,11 +59,12 @@ setup(
"pytest-xdist[psutil]",
"setuptools",
"vermin",
"typing-extensions",
],
},
entry_points={
'console_scripts': [
'toot=toot.console:main',
'toot=toot.cli:cli',
],
}
)

View File

@ -20,11 +20,10 @@ import psycopg2
import pytest
import uuid
from click.testing import CliRunner, Result
from pathlib import Path
from toot import api, App, User
from toot.console import run_command
from toot.exceptions import ApiError, ConsoleError
from toot.output import print_out
from toot.cli import Context, TootObj
def pytest_configure(config):
@ -34,6 +33,7 @@ def pytest_configure(config):
# Mastodon database name, used to confirm user registration without having to click the link
DATABASE_DSN = os.getenv("TOOT_TEST_DATABASE_DSN")
TOOT_TEST_BASE_URL = os.getenv("TOOT_TEST_BASE_URL")
# Toot logo used for testing image upload
TRUMPET = str(Path(__file__).parent.parent.parent / "trumpet.png")
@ -72,12 +72,10 @@ def confirm_user(email):
# DO NOT USE PUBLIC INSTANCES!!!
@pytest.fixture(scope="session")
def base_url():
base_url = os.getenv("TOOT_TEST_BASE_URL")
if not base_url:
if not TOOT_TEST_BASE_URL:
pytest.skip("Skipping integration tests, TOOT_TEST_BASE_URL not set")
return base_url
return TOOT_TEST_BASE_URL
@pytest.fixture(scope="session")
@ -105,37 +103,47 @@ def friend_id(app, user, friend):
return api.find_account(app, user, friend.username)["id"]
@pytest.fixture
def run(app, user, capsys):
def _run(command, *params, as_user=None):
# The try/catch duplicates logic from console.main to convert exceptions
# to printed error messages. TODO: could be deduped
try:
run_command(app, as_user or user, command, params or [])
except (ConsoleError, ApiError) as e:
print_out(str(e))
@pytest.fixture(scope="session", autouse=True)
def testing_env():
os.environ["TOOT_TESTING"] = "true"
out, err = capsys.readouterr()
assert err == ""
return strip_ansi(out)
@pytest.fixture(scope="session")
def runner():
return CliRunner(mix_stderr=False)
@pytest.fixture
def run(app, user, runner):
def _run(command, *params, input=None) -> Result:
obj = TootObj(test_ctx=Context(app, user))
return runner.invoke(command, params, obj=obj, input=input)
return _run
@pytest.fixture
def run_json(run):
def run_as(app, runner):
def _run_as(user, command, *params, input=None) -> Result:
obj = TootObj(test_ctx=Context(app, user))
return runner.invoke(command, params, obj=obj, input=input)
return _run_as
@pytest.fixture
def run_json(app, user, runner):
def _run_json(command, *params):
out = run(command, *params)
return json.loads(out)
obj = TootObj(test_ctx=Context(app, user))
result = runner.invoke(command, params, obj=obj)
assert result.exit_code == 0
return json.loads(result.stdout)
return _run_json
@pytest.fixture
def run_anon(capsys):
def _run(command, *params):
run_command(None, None, command, params or [])
out, err = capsys.readouterr()
assert err == ""
return strip_ansi(out)
def run_anon(runner):
def _run(command, *params) -> Result:
obj = TootObj(test_ctx=Context(None, None))
return runner.invoke(command, params, obj=obj)
return _run
@ -143,12 +151,6 @@ def run_anon(capsys):
# Utils
# ------------------------------------------------------------------------------
strip_ansi_pattern = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
def strip_ansi(string):
return strip_ansi_pattern.sub("", string).strip()
def posted_status_id(out):
pattern = re.compile(r"Toot posted: http://([^/]+)/([^/]+)/(.+)")

View File

@ -1,18 +1,23 @@
import json
from toot import App, User, api
from toot import App, User, api, cli
from toot.entities import Account, Relationship, from_dict
def test_whoami(user: User, run):
out = run("whoami")
result = run(cli.read.whoami)
assert result.exit_code == 0
# TODO: test other fields once updating account is supported
out = result.stdout.strip()
assert f"@{user.username}" in out
def test_whoami_json(user: User, run):
out = run("whoami", "--json")
account = from_dict(Account, json.loads(out))
result = run(cli.read.whoami, "--json")
assert result.exit_code == 0
account = from_dict(Account, json.loads(result.stdout))
assert account.username == user.username
@ -25,83 +30,95 @@ def test_whois(app: App, friend: User, run):
]
for username in variants:
out = run("whois", username)
assert f"@{friend.username}" in out
result = run(cli.read.whois, username)
assert result.exit_code == 0
assert f"@{friend.username}" in result.stdout
def test_following(app: App, user: User, friend: User, friend_id, run):
# Make sure we're not initally following friend
api.unfollow(app, user, friend_id)
out = run("following", user.username)
assert out == ""
result = run(cli.accounts.following, user.username)
assert result.exit_code == 0
assert result.stdout.strip() == ""
out = run("follow", friend.username)
assert out == f"✓ You are now following {friend.username}"
result = run(cli.accounts.follow, friend.username)
assert result.exit_code == 0
assert result.stdout.strip() == f"✓ You are now following {friend.username}"
out = run("following", user.username)
assert friend.username in out
result = run(cli.accounts.following, user.username)
assert result.exit_code == 0
assert friend.username in result.stdout.strip()
# If no account is given defaults to logged in user
out = run("following")
assert friend.username in out
result = run(cli.accounts.following)
assert result.exit_code == 0
assert friend.username in result.stdout.strip()
out = run("unfollow", friend.username)
assert out == f"✓ You are no longer following {friend.username}"
result = run(cli.accounts.unfollow, friend.username)
assert result.exit_code == 0
assert result.stdout.strip() == f"✓ You are no longer following {friend.username}"
out = run("following", user.username)
assert out == ""
result = run(cli.accounts.following, user.username)
assert result.exit_code == 0
assert result.stdout.strip() == ""
def test_following_case_insensitive(user: User, friend: User, run):
assert friend.username != friend.username.upper()
out = run("follow", friend.username.upper())
result = run(cli.accounts.follow, friend.username.upper())
assert result.exit_code == 0
out = result.stdout.strip()
assert out == f"✓ You are now following {friend.username.upper()}"
def test_following_not_found(run):
out = run("follow", "bananaman")
assert out == "Account not found"
result = run(cli.accounts.follow, "bananaman")
assert result.exit_code == 1
assert result.stderr.strip() == "Error: Account not found"
out = run("unfollow", "bananaman")
assert out == "Account not found"
result = run(cli.accounts.unfollow, "bananaman")
assert result.exit_code == 1
assert result.stderr.strip() == "Error: Account not found"
def test_following_json(app: App, user: User, friend: User, user_id, friend_id, run_json):
# Make sure we're not initally following friend
api.unfollow(app, user, friend_id)
result = run_json("following", user.username, "--json")
result = run_json(cli.accounts.following, user.username, "--json")
assert result == []
result = run_json("followers", friend.username, "--json")
result = run_json(cli.accounts.followers, friend.username, "--json")
assert result == []
result = run_json("follow", friend.username, "--json")
result = run_json(cli.accounts.follow, friend.username, "--json")
relationship = from_dict(Relationship, result)
assert relationship.id == friend_id
assert relationship.following is True
[result] = run_json("following", user.username, "--json")
[result] = run_json(cli.accounts.following, user.username, "--json")
relationship = from_dict(Relationship, result)
assert relationship.id == friend_id
# If no account is given defaults to logged in user
[result] = run_json("following", user.username, "--json")
[result] = run_json(cli.accounts.following, user.username, "--json")
relationship = from_dict(Relationship, result)
assert relationship.id == friend_id
[result] = run_json("followers", friend.username, "--json")
[result] = run_json(cli.accounts.followers, friend.username, "--json")
assert result["id"] == user_id
result = run_json("unfollow", friend.username, "--json")
result = run_json(cli.accounts.unfollow, friend.username, "--json")
assert result["id"] == friend_id
assert result["following"] is False
result = run_json("following", user.username, "--json")
result = run_json(cli.accounts.following, user.username, "--json")
assert result == []
result = run_json("followers", friend.username, "--json")
result = run_json(cli.accounts.followers, friend.username, "--json")
assert result == []
@ -109,57 +126,77 @@ def test_mute(app, user, friend, friend_id, run):
# Make sure we're not initially muting friend
api.unmute(app, user, friend_id)
out = run("muted")
result = run(cli.accounts.muted)
assert result.exit_code == 0
out = result.stdout.strip()
assert out == "No accounts muted"
out = run("mute", friend.username)
result = run(cli.accounts.mute, friend.username)
assert result.exit_code == 0
out = result.stdout.strip()
assert out == f"✓ You have muted {friend.username}"
out = run("muted")
result = run(cli.accounts.muted)
assert result.exit_code == 0
out = result.stdout.strip()
assert friend.username in out
out = run("unmute", friend.username)
result = run(cli.accounts.unmute, friend.username)
assert result.exit_code == 0
out = result.stdout.strip()
assert out == f"{friend.username} is no longer muted"
out = run("muted")
result = run(cli.accounts.muted)
assert result.exit_code == 0
out = result.stdout.strip()
assert out == "No accounts muted"
def test_mute_case_insensitive(friend: User, run):
out = run("mute", friend.username.upper())
result = run(cli.accounts.mute, friend.username.upper())
assert result.exit_code == 0
out = result.stdout.strip()
assert out == f"✓ You have muted {friend.username.upper()}"
def test_mute_not_found(run):
out = run("mute", "doesnotexistperson")
assert out == f"Account not found"
result = run(cli.accounts.mute, "doesnotexistperson")
assert result.exit_code == 1
assert result.stderr.strip() == "Error: Account not found"
out = run("unmute", "doesnotexistperson")
assert out == f"Account not found"
result = run(cli.accounts.unmute, "doesnotexistperson")
assert result.exit_code == 1
assert result.stderr.strip() == "Error: Account not found"
def test_mute_json(app: App, user: User, friend: User, run_json, friend_id):
# Make sure we're not initially muting friend
api.unmute(app, user, friend_id)
result = run_json("muted", "--json")
result = run_json(cli.accounts.muted, "--json")
assert result == []
result = run_json("mute", friend.username, "--json")
result = run_json(cli.accounts.mute, friend.username, "--json")
relationship = from_dict(Relationship, result)
assert relationship.id == friend_id
assert relationship.muting is True
[result] = run_json("muted", "--json")
[result] = run_json(cli.accounts.muted, "--json")
account = from_dict(Account, result)
assert account.id == friend_id
result = run_json("unmute", friend.username, "--json")
result = run_json(cli.accounts.unmute, friend.username, "--json")
relationship = from_dict(Relationship, result)
assert relationship.id == friend_id
assert relationship.muting is False
result = run_json("muted", "--json")
result = run_json(cli.accounts.muted, "--json")
assert result == []
@ -167,52 +204,71 @@ def test_block(app, user, friend, friend_id, run):
# Make sure we're not initially blocking friend
api.unblock(app, user, friend_id)
out = run("blocked")
result = run(cli.accounts.blocked)
assert result.exit_code == 0
out = result.stdout.strip()
assert out == "No accounts blocked"
out = run("block", friend.username)
result = run(cli.accounts.block, friend.username)
assert result.exit_code == 0
out = result.stdout.strip()
assert out == f"✓ You are now blocking {friend.username}"
out = run("blocked")
result = run(cli.accounts.blocked)
assert result.exit_code == 0
out = result.stdout.strip()
assert friend.username in out
out = run("unblock", friend.username)
result = run(cli.accounts.unblock, friend.username)
assert result.exit_code == 0
out = result.stdout.strip()
assert out == f"{friend.username} is no longer blocked"
out = run("blocked")
result = run(cli.accounts.blocked)
assert result.exit_code == 0
out = result.stdout.strip()
assert out == "No accounts blocked"
def test_block_case_insensitive(friend: User, run):
out = run("block", friend.username.upper())
result = run(cli.accounts.block, friend.username.upper())
assert result.exit_code == 0
out = result.stdout.strip()
assert out == f"✓ You are now blocking {friend.username.upper()}"
def test_block_not_found(run):
out = run("block", "doesnotexistperson")
assert out == f"Account not found"
result = run(cli.accounts.block, "doesnotexistperson")
assert result.exit_code == 1
assert result.stderr.strip() == "Error: Account not found"
def test_block_json(app: App, user: User, friend: User, run_json, friend_id):
# Make sure we're not initially blocking friend
api.unblock(app, user, friend_id)
result = run_json("blocked", "--json")
result = run_json(cli.accounts.blocked, "--json")
assert result == []
result = run_json("block", friend.username, "--json")
result = run_json(cli.accounts.block, friend.username, "--json")
relationship = from_dict(Relationship, result)
assert relationship.id == friend_id
assert relationship.blocking is True
[result] = run_json("blocked", "--json")
[result] = run_json(cli.accounts.blocked, "--json")
account = from_dict(Account, result)
assert account.id == friend_id
result = run_json("unblock", friend.username, "--json")
result = run_json(cli.accounts.unblock, friend.username, "--json")
relationship = from_dict(Relationship, result)
assert relationship.id == friend_id
assert relationship.blocking is False
result = run_json("blocked", "--json")
result = run_json(cli.accounts.blocked, "--json")
assert result == []

View File

@ -1,129 +1,217 @@
from tests.integration.conftest import TRUMPET
from toot import api
from toot.entities import Account, from_dict
from toot.utils import get_text
from typing import Any, Dict
from unittest import mock
from unittest.mock import MagicMock
from toot import User, cli
from toot.cli import Run
# TODO: figure out how to test login
def test_update_account_no_options(run):
out = run("update_account")
assert out == "Please specify at least one option to update the account"
EMPTY_CONFIG: Dict[Any, Any] = {
"apps": {},
"users": {},
"active_user": None
}
SAMPLE_CONFIG = {
"active_user": "frank@foo.social",
"apps": {
"foo.social": {
"base_url": "http://foo.social",
"client_id": "123",
"client_secret": "123",
"instance": "foo.social"
},
"bar.social": {
"base_url": "http://bar.social",
"client_id": "123",
"client_secret": "123",
"instance": "bar.social"
},
},
"users": {
"frank@foo.social": {
"access_token": "123",
"instance": "foo.social",
"username": "frank"
},
"frank@bar.social": {
"access_token": "123",
"instance": "bar.social",
"username": "frank"
},
}
}
def test_update_account_display_name(run, app, user):
out = run("update_account", "--display-name", "elwood")
assert out == "✓ Account updated"
account = api.verify_credentials(app, user).json()
assert account["display_name"] == "elwood"
def test_env(run: Run):
result = run(cli.auth.env)
assert result.exit_code == 0
assert "toot" in result.stdout
assert "Python" in result.stdout
def test_update_account_json(run_json, app, user):
out = run_json("update_account", "--display-name", "elwood", "--json")
account = from_dict(Account, out)
assert account.acct == user.username
assert account.display_name == "elwood"
@mock.patch("toot.config.load_config")
def test_auth_empty(load_config: MagicMock, run: Run):
load_config.return_value = EMPTY_CONFIG
result = run(cli.auth.auth)
assert result.exit_code == 0
assert result.stdout.strip() == "You are not logged in to any accounts"
def test_update_account_note(run, app, user):
note = ("It's 106 miles to Chicago, we got a full tank of gas, half a pack "
"of cigarettes, it's dark... and we're wearing sunglasses.")
out = run("update_account", "--note", note)
assert out == "✓ Account updated"
account = api.verify_credentials(app, user).json()
assert get_text(account["note"]) == note
@mock.patch("toot.config.load_config")
def test_auth_full(load_config: MagicMock, run: Run):
load_config.return_value = SAMPLE_CONFIG
result = run(cli.auth.auth)
assert result.exit_code == 0
assert result.stdout.strip().startswith("Authenticated accounts:")
assert "frank@foo.social" in result.stdout
assert "frank@bar.social" in result.stdout
def test_update_account_language(run, app, user):
out = run("update_account", "--language", "hr")
assert out == "✓ Account updated"
# Saving config is mocked so we don't mess up our local config
# TODO: could this be implemented using an auto-use fixture so we have it always
# mocked?
@mock.patch("toot.config.load_app")
@mock.patch("toot.config.save_app")
@mock.patch("toot.config.save_user")
def test_login_cli(
save_user: MagicMock,
save_app: MagicMock,
load_app: MagicMock,
user: User,
run: Run,
):
load_app.return_value = None
account = api.verify_credentials(app, user).json()
assert account["source"]["language"] == "hr"
result = run(
cli.auth.login_cli,
"--instance", "http://localhost:3000",
"--email", f"{user.username}@example.com",
"--password", "password",
)
assert result.exit_code == 0
assert "✓ Successfully logged in." in result.stdout
save_app.assert_called_once()
(app,) = save_app.call_args.args
assert app.instance == "localhost:3000"
assert app.base_url == "http://localhost:3000"
assert app.client_id
assert app.client_secret
save_user.assert_called_once()
(new_user,) = save_user.call_args.args
assert new_user.instance == "localhost:3000"
assert new_user.username == user.username
# access token will be different since this is a new login
assert new_user.access_token and new_user.access_token != user.access_token
assert save_user.call_args.kwargs == {"activate": True}
def test_update_account_privacy(run, app, user):
out = run("update_account", "--privacy", "private")
assert out == "✓ Account updated"
@mock.patch("toot.config.load_app")
@mock.patch("toot.config.save_app")
@mock.patch("toot.config.save_user")
def test_login_cli_wrong_password(
save_user: MagicMock,
save_app: MagicMock,
load_app: MagicMock,
user: User,
run: Run,
):
load_app.return_value = None
account = api.verify_credentials(app, user).json()
assert account["source"]["privacy"] == "private"
result = run(
cli.auth.login_cli,
"--instance", "http://localhost:3000",
"--email", f"{user.username}@example.com",
"--password", "wrong password",
)
assert result.exit_code == 1
assert result.stderr.strip() == "Error: Login failed"
save_app.assert_called_once()
(app,) = save_app.call_args.args
assert app.instance == "localhost:3000"
assert app.base_url == "http://localhost:3000"
assert app.client_id
assert app.client_secret
save_user.assert_not_called()
def test_update_account_avatar(run, app, user):
account = api.verify_credentials(app, user).json()
old_value = account["avatar"]
@mock.patch("toot.config.load_config")
@mock.patch("toot.config.delete_user")
def test_logout(delete_user: MagicMock, load_config: MagicMock, run: Run):
load_config.return_value = SAMPLE_CONFIG
out = run("update_account", "--avatar", TRUMPET)
assert out == "✓ Account updated"
account = api.verify_credentials(app, user).json()
assert account["avatar"] != old_value
result = run(cli.auth.logout, "frank@foo.social")
assert result.exit_code == 0
assert result.stdout.strip() == "✓ Account frank@foo.social logged out"
delete_user.assert_called_once_with(User("foo.social", "frank", "123"))
def test_update_account_header(run, app, user):
account = api.verify_credentials(app, user).json()
old_value = account["header"]
@mock.patch("toot.config.load_config")
def test_logout_not_logged_in(load_config: MagicMock, run: Run):
load_config.return_value = EMPTY_CONFIG
out = run("update_account", "--header", TRUMPET)
assert out == "✓ Account updated"
account = api.verify_credentials(app, user).json()
assert account["header"] != old_value
result = run(cli.auth.logout)
assert result.exit_code == 1
assert result.stderr.strip() == "Error: You're not logged into any accounts"
def test_update_account_locked(run, app, user):
out = run("update_account", "--locked")
assert out == "✓ Account updated"
@mock.patch("toot.config.load_config")
def test_logout_account_not_specified(load_config: MagicMock, run: Run):
load_config.return_value = SAMPLE_CONFIG
account = api.verify_credentials(app, user).json()
assert account["locked"] is True
out = run("update_account", "--no-locked")
assert out == "✓ Account updated"
account = api.verify_credentials(app, user).json()
assert account["locked"] is False
result = run(cli.auth.logout)
assert result.exit_code == 1
assert result.stderr.startswith("Error: Specify account to log out")
def test_update_account_bot(run, app, user):
out = run("update_account", "--bot")
assert out == "✓ Account updated"
@mock.patch("toot.config.load_config")
def test_logout_account_does_not_exist(load_config: MagicMock, run: Run):
load_config.return_value = SAMPLE_CONFIG
account = api.verify_credentials(app, user).json()
assert account["bot"] is True
out = run("update_account", "--no-bot")
assert out == "✓ Account updated"
account = api.verify_credentials(app, user).json()
assert account["bot"] is False
result = run(cli.auth.logout, "banana")
assert result.exit_code == 1
assert result.stderr.startswith("Error: Account not found")
def test_update_account_discoverable(run, app, user):
out = run("update_account", "--discoverable")
assert out == "✓ Account updated"
@mock.patch("toot.config.load_config")
@mock.patch("toot.config.activate_user")
def test_activate(activate_user: MagicMock, load_config: MagicMock, run: Run):
load_config.return_value = SAMPLE_CONFIG
account = api.verify_credentials(app, user).json()
assert account["discoverable"] is True
out = run("update_account", "--no-discoverable")
assert out == "✓ Account updated"
account = api.verify_credentials(app, user).json()
assert account["discoverable"] is False
result = run(cli.auth.activate, "frank@foo.social")
assert result.exit_code == 0
assert result.stdout.strip() == "✓ Account frank@foo.social activated"
activate_user.assert_called_once_with(User("foo.social", "frank", "123"))
def test_update_account_sensitive(run, app, user):
out = run("update_account", "--sensitive")
assert out == "✓ Account updated"
@mock.patch("toot.config.load_config")
def test_activate_not_logged_in(load_config: MagicMock, run: Run):
load_config.return_value = EMPTY_CONFIG
account = api.verify_credentials(app, user).json()
assert account["source"]["sensitive"] is True
result = run(cli.auth.activate)
assert result.exit_code == 1
assert result.stderr.strip() == "Error: You're not logged into any accounts"
out = run("update_account", "--no-sensitive")
assert out == "✓ Account updated"
account = api.verify_credentials(app, user).json()
assert account["source"]["sensitive"] is False
@mock.patch("toot.config.load_config")
def test_activate_account_not_given(load_config: MagicMock, run: Run):
load_config.return_value = SAMPLE_CONFIG
result = run(cli.auth.activate)
assert result.exit_code == 1
assert result.stderr.startswith("Error: Specify account to activate")
@mock.patch("toot.config.load_config")
def test_activate_invalid_Account(load_config: MagicMock, run: Run):
load_config.return_value = SAMPLE_CONFIG
result = run(cli.auth.activate, "banana")
assert result.exit_code == 1
assert result.stderr.startswith("Error: Account not found")

View File

@ -1,67 +1,86 @@
from toot import cli
from tests.integration.conftest import register_account
def test_lists_empty(run):
out = run("lists")
assert out == "You have no lists defined."
result = run(cli.lists.list)
assert result.exit_code == 0
assert result.stdout.strip() == "You have no lists defined."
def test_list_create_delete(run):
out = run("list_create", "banana")
assert out == '✓ List "banana" created.'
result = run(cli.lists.create, "banana")
assert result.exit_code == 0
assert result.stdout.strip() == '✓ List "banana" created.'
out = run("lists")
assert "banana" in out
result = run(cli.lists.list)
assert result.exit_code == 0
assert "banana" in result.stdout
out = run("list_create", "mango")
assert out == '✓ List "mango" created.'
result = run(cli.lists.create, "mango")
assert result.exit_code == 0
assert result.stdout.strip() == '✓ List "mango" created.'
out = run("lists")
assert "banana" in out
assert "mango" in out
result = run(cli.lists.list)
assert result.exit_code == 0
assert "banana" in result.stdout
assert "mango" in result.stdout
out = run("list_delete", "banana")
assert out == '✓ List "banana" deleted.'
result = run(cli.lists.delete, "banana")
assert result.exit_code == 0
assert result.stdout.strip() == '✓ List "banana" deleted.'
out = run("lists")
assert "banana" not in out
assert "mango" in out
result = run(cli.lists.list)
assert result.exit_code == 0
assert "banana" not in result.stdout
assert "mango" in result.stdout
out = run("list_delete", "mango")
assert out == '✓ List "mango" deleted.'
result = run(cli.lists.delete, "mango")
assert result.exit_code == 0
assert result.stdout.strip() == '✓ List "mango" deleted.'
out = run("lists")
assert out == "You have no lists defined."
result = run(cli.lists.list)
assert result.exit_code == 0
assert result.stdout.strip() == "You have no lists defined."
out = run("list_delete", "mango")
assert out == "List not found"
result = run(cli.lists.delete, "mango")
assert result.exit_code == 1
assert result.stderr.strip() == "Error: List not found"
def test_list_add_remove(run, app):
acc = register_account(app)
run("list_create", "foo")
run(cli.lists.create, "foo")
out = run("list_add", "foo", acc.username)
assert out == f"You must follow @{acc.username} before adding this account to a list."
result = run(cli.lists.add, "foo", acc.username)
assert result.exit_code == 1
assert result.stderr.strip() == f"Error: You must follow @{acc.username} before adding this account to a list."
run("follow", acc.username)
run(cli.accounts.follow, acc.username)
out = run("list_add", "foo", acc.username)
assert out == f'✓ Added account "{acc.username}"'
result = run(cli.lists.add, "foo", acc.username)
assert result.exit_code == 0
assert result.stdout.strip() == f'✓ Added account "{acc.username}"'
out = run("list_accounts", "foo")
assert acc.username in out
result = run(cli.lists.accounts, "foo")
assert result.exit_code == 0
assert acc.username in result.stdout
# Account doesn't exist
out = run("list_add", "foo", "does_not_exist")
assert out == "Account not found"
result = run(cli.lists.add, "foo", "does_not_exist")
assert result.exit_code == 1
assert result.stderr.strip() == "Error: Account not found"
# List doesn't exist
out = run("list_add", "does_not_exist", acc.username)
assert out == "List not found"
result = run(cli.lists.add, "does_not_exist", acc.username)
assert result.exit_code == 1
assert result.stderr.strip() == "Error: List not found"
out = run("list_remove", "foo", acc.username)
assert out == f'✓ Removed account "{acc.username}"'
result = run(cli.lists.remove, "foo", acc.username)
assert result.exit_code == 0
assert result.stdout.strip() == f'✓ Removed account "{acc.username}"'
out = run("list_accounts", "foo")
assert out == "This list has no accounts."
result = run(cli.lists.accounts, "foo")
assert result.exit_code == 0
assert result.stdout.strip() == "This list has no accounts."

View File

@ -5,15 +5,17 @@ import uuid
from datetime import datetime, timedelta, timezone
from os import path
from tests.integration.conftest import ASSETS_DIR, posted_status_id
from toot import CLIENT_NAME, CLIENT_WEBSITE, api
from toot import CLIENT_NAME, CLIENT_WEBSITE, api, cli
from toot.utils import get_text
from unittest import mock
def test_post(app, user, run):
text = "i wish i was a #lumberjack"
out = run("post", text)
status_id = posted_status_id(out)
result = run(cli.post.post, text)
assert result.exit_code == 0
status_id = posted_status_id(result.stdout)
status = api.fetch_status(app, user, status_id).json()
assert text == get_text(status["content"])
@ -28,11 +30,18 @@ def test_post(app, user, run):
assert status["application"]["website"] == CLIENT_WEBSITE
def test_post_no_text(run):
result = run(cli.post.post)
assert result.exit_code == 1
assert result.stderr.strip() == "Error: You must specify either text or media to post."
def test_post_json(run):
content = "i wish i was a #lumberjack"
out = run("post", content, "--json")
status = json.loads(out)
result = run(cli.post.post, content, "--json")
assert result.exit_code == 0
status = json.loads(result.stdout)
assert get_text(status["content"]) == content
assert status["visibility"] == "public"
assert status["sensitive"] is False
@ -42,8 +51,10 @@ def test_post_json(run):
def test_post_visibility(app, user, run):
for visibility in ["public", "unlisted", "private", "direct"]:
out = run("post", "foo", "--visibility", visibility)
status_id = posted_status_id(out)
result = run(cli.post.post, "foo", "--visibility", visibility)
assert result.exit_code == 0
status_id = posted_status_id(result.stdout)
status = api.fetch_status(app, user, status_id).json()
assert status["visibility"] == visibility
@ -52,14 +63,23 @@ def test_post_scheduled_at(app, user, run):
text = str(uuid.uuid4())
scheduled_at = datetime.now(timezone.utc).replace(microsecond=0) + timedelta(minutes=10)
out = run("post", text, "--scheduled-at", scheduled_at.isoformat())
assert "Toot scheduled for" in out
result = run(cli.post.post, text, "--scheduled-at", scheduled_at.isoformat())
assert result.exit_code == 0
assert "Toot scheduled for" in result.stdout
statuses = api.scheduled_statuses(app, user)
[status] = [s for s in statuses if s["params"]["text"] == text]
assert datetime.strptime(status["scheduled_at"], "%Y-%m-%dT%H:%M:%S.%f%z") == scheduled_at
def test_post_scheduled_at_error(run):
result = run(cli.post.post, "foo", "--scheduled-at", "banana")
assert result.exit_code == 1
# Stupid error returned by mastodon
assert result.stderr.strip() == "Error: Record invalid"
def test_post_scheduled_in(app, user, run):
text = str(uuid.uuid4())
@ -76,9 +96,11 @@ def test_post_scheduled_in(app, user, run):
datetimes = []
for scheduled_in, delta in variants:
out = run("post", text, "--scheduled-in", scheduled_in)
result = run(cli.post.post, text, "--scheduled-in", scheduled_in)
assert result.exit_code == 0
dttm = datetime.utcnow() + delta
assert out.startswith(f"Toot scheduled for: {str(dttm)[:16]}")
assert result.stdout.startswith(f"Toot scheduled for: {str(dttm)[:16]}")
datetimes.append(dttm)
scheduled = api.scheduled_statuses(app, user)
@ -92,18 +114,31 @@ def test_post_scheduled_in(app, user, run):
assert delta.total_seconds() < 5
def test_post_scheduled_in_invalid_duration(run):
result = run(cli.post.post, "foo", "--scheduled-in", "banana")
assert result.exit_code == 2
assert "Invalid duration: banana" in result.stderr
def test_post_scheduled_in_empty_duration(run):
result = run(cli.post.post, "foo", "--scheduled-in", "0m")
assert result.exit_code == 2
assert "Empty duration" in result.stderr
def test_post_poll(app, user, run):
text = str(uuid.uuid4())
out = run(
"post", text,
result = run(
cli.post.post, text,
"--poll-option", "foo",
"--poll-option", "bar",
"--poll-option", "baz",
"--poll-option", "qux",
)
status_id = posted_status_id(out)
assert result.exit_code == 0
status_id = posted_status_id(result.stdout)
status = api.fetch_status(app, user, status_id).json()
assert status["poll"]["expired"] is False
@ -125,15 +160,15 @@ def test_post_poll(app, user, run):
def test_post_poll_multiple(app, user, run):
text = str(uuid.uuid4())
out = run(
"post", text,
result = run(
cli.post.post, text,
"--poll-option", "foo",
"--poll-option", "bar",
"--poll-multiple"
)
assert result.exit_code == 0
status_id = posted_status_id(out)
status_id = posted_status_id(result.stdout)
status = api.fetch_status(app, user, status_id).json()
assert status["poll"]["multiple"] is True
@ -141,14 +176,15 @@ def test_post_poll_multiple(app, user, run):
def test_post_poll_expires_in(app, user, run):
text = str(uuid.uuid4())
out = run(
"post", text,
result = run(
cli.post.post, text,
"--poll-option", "foo",
"--poll-option", "bar",
"--poll-expires-in", "8h",
)
assert result.exit_code == 0
status_id = posted_status_id(out)
status_id = posted_status_id(result.stdout)
status = api.fetch_status(app, user, status_id).json()
actual = datetime.strptime(status["poll"]["expires_at"], "%Y-%m-%dT%H:%M:%S.%f%z")
@ -160,14 +196,15 @@ def test_post_poll_expires_in(app, user, run):
def test_post_poll_hide_totals(app, user, run):
text = str(uuid.uuid4())
out = run(
"post", text,
result = run(
cli.post.post, text,
"--poll-option", "foo",
"--poll-option", "bar",
"--poll-hide-totals"
)
assert result.exit_code == 0
status_id = posted_status_id(out)
status_id = posted_status_id(result.stdout)
status = api.fetch_status(app, user, status_id).json()
@ -179,30 +216,41 @@ def test_post_poll_hide_totals(app, user, run):
def test_post_language(app, user, run):
out = run("post", "test", "--language", "hr")
status_id = posted_status_id(out)
result = run(cli.post.post, "test", "--language", "hr")
assert result.exit_code == 0
status_id = posted_status_id(result.stdout)
status = api.fetch_status(app, user, status_id).json()
assert status["language"] == "hr"
out = run("post", "test", "--language", "zh")
status_id = posted_status_id(out)
result = run(cli.post.post, "test", "--language", "zh")
assert result.exit_code == 0
status_id = posted_status_id(result.stdout)
status = api.fetch_status(app, user, status_id).json()
assert status["language"] == "zh"
def test_post_language_error(run):
result = run(cli.post.post, "test", "--language", "banana")
assert result.exit_code == 2
assert "Language should be a two letter abbreviation." in result.stderr
def test_media_thumbnail(app, user, run):
video_path = path.join(ASSETS_DIR, "small.webm")
thumbnail_path = path.join(ASSETS_DIR, "test1.png")
out = run(
"post",
result = run(
cli.post.post,
"--media", video_path,
"--thumbnail", thumbnail_path,
"--description", "foo",
"some text"
)
assert result.exit_code == 0
status_id = posted_status_id(out)
status_id = posted_status_id(result.stdout)
status = api.fetch_status(app, user, status_id).json()
[media] = status["media_attachments"]
@ -227,8 +275,8 @@ def test_media_attachments(app, user, run):
path3 = path.join(ASSETS_DIR, "test3.png")
path4 = path.join(ASSETS_DIR, "test4.png")
out = run(
"post",
result = run(
cli.post.post,
"--media", path1,
"--media", path2,
"--media", path3,
@ -239,8 +287,9 @@ def test_media_attachments(app, user, run):
"--description", "Test 4",
"some text"
)
assert result.exit_code == 0
status_id = posted_status_id(out)
status_id = posted_status_id(result.stdout)
status = api.fetch_status(app, user, status_id).json()
[a1, a2, a3, a4] = status["media_attachments"]
@ -258,6 +307,13 @@ def test_media_attachments(app, user, run):
assert a4["description"] == "Test 4"
def test_too_many_media(run):
m = path.join(ASSETS_DIR, "test1.png")
result = run(cli.post.post, "-m", m, "-m", m, "-m", m, "-m", m, "-m", m)
assert result.exit_code == 1
assert result.stderr.strip() == "Error: Cannot attach more than 4 files."
@mock.patch("toot.utils.multiline_input")
@mock.patch("sys.stdin.read")
def test_media_attachment_without_text(mock_read, mock_ml, app, user, run):
@ -267,8 +323,10 @@ def test_media_attachment_without_text(mock_read, mock_ml, app, user, run):
media_path = path.join(ASSETS_DIR, "test1.png")
out = run("post", "--media", media_path)
status_id = posted_status_id(out)
result = run(cli.post.post, "--media", media_path)
assert result.exit_code == 0
status_id = posted_status_id(result.stdout)
status = api.fetch_status(app, user, status_id).json()
assert status["content"] == ""
@ -284,14 +342,18 @@ def test_media_attachment_without_text(mock_read, mock_ml, app, user, run):
def test_reply_thread(app, user, friend, run):
status = api.post_status(app, friend, "This is the status").json()
out = run("post", "--reply-to", status["id"], "This is the reply")
status_id = posted_status_id(out)
result = run(cli.post.post, "--reply-to", status["id"], "This is the reply")
assert result.exit_code == 0
status_id = posted_status_id(result.stdout)
reply = api.fetch_status(app, user, status_id).json()
assert reply["in_reply_to_id"] == status["id"]
out = run("thread", status["id"])
[s1, s2] = [s.strip() for s in re.split(r"─+", out) if s.strip()]
result = run(cli.read.thread, status["id"])
assert result.exit_code == 0
[s1, s2] = [s.strip() for s in re.split(r"─+", result.stdout) if s.strip()]
assert "This is the status" in s1
assert "This is the reply" in s2

View File

@ -1,45 +1,68 @@
import json
from pprint import pprint
import pytest
import re
from toot import api
from toot.entities import Account, from_dict_list
from toot.exceptions import ConsoleError
from tests.integration.conftest import TOOT_TEST_BASE_URL
from toot import api, cli
from toot.entities import Account, Status, from_dict, from_dict_list
from uuid import uuid4
def test_instance(app, run):
out = run("instance", "--disable-https")
assert "Mastodon" in out
assert app.instance in out
assert "running Mastodon" in out
def test_instance_default(app, run):
result = run(cli.read.instance)
assert result.exit_code == 0
assert "Mastodon" in result.stdout
assert app.instance in result.stdout
assert "running Mastodon" in result.stdout
def test_instance_with_url(app, run):
result = run(cli.read.instance, TOOT_TEST_BASE_URL)
assert result.exit_code == 0
assert "Mastodon" in result.stdout
assert app.instance in result.stdout
assert "running Mastodon" in result.stdout
def test_instance_json(app, run):
out = run("instance", "--json")
data = json.loads(out)
result = run(cli.read.instance, "--json")
assert result.exit_code == 0
data = json.loads(result.stdout)
assert data["title"] is not None
assert data["description"] is not None
assert data["version"] is not None
def test_instance_anon(app, run_anon, base_url):
out = run_anon("instance", base_url)
assert "Mastodon" in out
assert app.instance in out
assert "running Mastodon" in out
result = run_anon(cli.read.instance, base_url)
assert result.exit_code == 0
assert "Mastodon" in result.stdout
assert app.instance in result.stdout
assert "running Mastodon" in result.stdout
# Need to specify the instance name when running anon
with pytest.raises(ConsoleError) as exc:
run_anon("instance")
assert str(exc.value) == "Please specify an instance."
result = run_anon(cli.read.instance)
assert result.exit_code == 1
assert result.stderr == "Error: Please specify an instance.\n"
def test_whoami(user, run):
out = run("whoami")
# TODO: test other fields once updating account is supported
assert f"@{user.username}" in out
result = run(cli.read.whoami)
assert result.exit_code == 0
assert f"@{user.username}" in result.stdout
def test_whoami_json(user, run):
result = run(cli.read.whoami, "--json")
assert result.exit_code == 0
data = json.loads(result.stdout)
account = from_dict(Account, data)
assert account.username == user.username
assert account.acct == user.username
def test_whois(app, friend, run):
@ -51,18 +74,33 @@ def test_whois(app, friend, run):
]
for username in variants:
out = run("whois", username)
assert f"@{friend.username}" in out
result = run(cli.read.whois, username)
assert result.exit_code == 0
assert f"@{friend.username}" in result.stdout
def test_whois_json(app, friend, run):
result = run(cli.read.whois, friend.username, "--json")
assert result.exit_code == 0
data = json.loads(result.stdout)
account = from_dict(Account, data)
assert account.username == friend.username
assert account.acct == friend.username
def test_search_account(friend, run):
out = run("search", friend.username)
assert out == f"Accounts:\n* @{friend.username}"
result = run(cli.read.search, friend.username)
assert result.exit_code == 0
assert result.stdout.strip() == f"Accounts:\n* @{friend.username}"
def test_search_account_json(friend, run_json):
out = run_json("search", friend.username, "--json")
[account] = from_dict_list(Account, out["accounts"])
def test_search_account_json(friend, run):
result = run(cli.read.search, friend.username, "--json")
assert result.exit_code == 0
data = json.loads(result.stdout)
[account] = from_dict_list(Account, data["accounts"])
assert account.acct == friend.username
@ -71,68 +109,67 @@ def test_search_hashtag(app, user, run):
api.post_status(app, user, "#hashtag_y")
api.post_status(app, user, "#hashtag_z")
out = run("search", "#hashtag")
assert out == "Hashtags:\n#hashtag_x, #hashtag_y, #hashtag_z"
result = run(cli.read.search, "#hashtag")
assert result.exit_code == 0
assert result.stdout.strip() == "Hashtags:\n#hashtag_x, #hashtag_y, #hashtag_z"
def test_search_hashtag_json(app, user, run_json):
def test_search_hashtag_json(app, user, run):
api.post_status(app, user, "#hashtag_x")
api.post_status(app, user, "#hashtag_y")
api.post_status(app, user, "#hashtag_z")
out = run_json("search", "#hashtag", "--json")
[h1, h2, h3] = sorted(out["hashtags"], key=lambda h: h["name"])
result = run(cli.read.search, "#hashtag", "--json")
assert result.exit_code == 0
data = json.loads(result.stdout)
[h1, h2, h3] = sorted(data["hashtags"], key=lambda h: h["name"])
assert h1["name"] == "hashtag_x"
assert h2["name"] == "hashtag_y"
assert h3["name"] == "hashtag_z"
def test_tags(run, base_url):
out = run("tags_followed")
assert out == "You're not following any hashtags."
out = run("tags_follow", "foo")
assert out == "✓ You are now following #foo"
out = run("tags_followed")
assert out == f"* #foo\t{base_url}/tags/foo"
out = run("tags_follow", "bar")
assert out == "✓ You are now following #bar"
out = run("tags_followed")
assert out == "\n".join([
f"* #bar\t{base_url}/tags/bar",
f"* #foo\t{base_url}/tags/foo",
])
out = run("tags_unfollow", "foo")
assert out == "✓ You are no longer following #foo"
out = run("tags_followed")
assert out == f"* #bar\t{base_url}/tags/bar"
def test_status(app, user, run):
uuid = str(uuid4())
response = api.post_status(app, user, uuid).json()
status_id = api.post_status(app, user, uuid).json()["id"]
out = run("status", response["id"])
result = run(cli.read.status, status_id)
assert result.exit_code == 0
out = result.stdout.strip()
assert uuid in out
assert user.username in out
assert response["id"] in out
assert status_id in out
def test_status_json(app, user, run):
uuid = str(uuid4())
status_id = api.post_status(app, user, uuid).json()["id"]
result = run(cli.read.status, status_id, "--json")
assert result.exit_code == 0
status = from_dict(Status, json.loads(result.stdout))
assert status.id == status_id
assert status.account.acct == user.username
assert uuid in status.content
def test_thread(app, user, run):
uuid = str(uuid4())
s1 = api.post_status(app, user, uuid + "1").json()
s2 = api.post_status(app, user, uuid + "2", in_reply_to_id=s1["id"]).json()
s3 = api.post_status(app, user, uuid + "3", in_reply_to_id=s2["id"]).json()
uuid1 = str(uuid4())
uuid2 = str(uuid4())
uuid3 = str(uuid4())
s1 = api.post_status(app, user, uuid1).json()
s2 = api.post_status(app, user, uuid2, in_reply_to_id=s1["id"]).json()
s3 = api.post_status(app, user, uuid3, in_reply_to_id=s2["id"]).json()
for status in [s1, s2, s3]:
out = run("thread", status["id"])
bits = re.split(r"─+", out)
result = run(cli.read.thread, status["id"])
assert result.exit_code == 0
bits = re.split(r"─+", result.stdout.strip())
bits = [b for b in bits if b]
assert len(bits) == 3
@ -141,6 +178,26 @@ def test_thread(app, user, run):
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]
assert uuid1 in bits[0]
assert uuid2 in bits[1]
assert uuid3 in bits[2]
def test_thread_json(app, user, run):
uuid1 = str(uuid4())
uuid2 = str(uuid4())
uuid3 = str(uuid4())
s1 = api.post_status(app, user, uuid1).json()
s2 = api.post_status(app, user, uuid2, in_reply_to_id=s1["id"]).json()
s3 = api.post_status(app, user, uuid3, in_reply_to_id=s2["id"]).json()
result = run(cli.read.thread, s2["id"], "--json")
assert result.exit_code == 0
result = json.loads(result.stdout)
[ancestor] = [from_dict(Status, s) for s in result["ancestors"]]
[descendent] = [from_dict(Status, s) for s in result["descendants"]]
assert ancestor.id == s1["id"]
assert descendent.id == s3["id"]

View File

@ -2,15 +2,16 @@ import json
import time
import pytest
from toot import api
from toot import api, cli
from toot.exceptions import NotFoundError
def test_delete(app, user, run):
status = api.post_status(app, user, "foo").json()
out = run("delete", status["id"])
assert out == "✓ Status deleted"
result = run(cli.statuses.delete, status["id"])
assert result.exit_code == 0
assert result.stdout.strip() == "✓ Status deleted"
with pytest.raises(NotFoundError):
api.fetch_status(app, user, status["id"])
@ -19,7 +20,10 @@ def test_delete(app, user, run):
def test_delete_json(app, user, run):
status = api.post_status(app, user, "foo").json()
out = run("delete", status["id"], "--json")
result = run(cli.statuses.delete, status["id"], "--json")
assert result.exit_code == 0
out = result.stdout
result = json.loads(out)
assert result["id"] == status["id"]
@ -31,17 +35,19 @@ def test_favourite(app, user, run):
status = api.post_status(app, user, "foo").json()
assert not status["favourited"]
out = run("favourite", status["id"])
assert out == "✓ Status favourited"
result = run(cli.statuses.favourite, status["id"])
assert result.exit_code == 0
assert result.stdout.strip() == "✓ Status favourited"
status = api.fetch_status(app, user, status["id"]).json()
assert status["favourited"]
out = run("unfavourite", status["id"])
assert out == "✓ Status unfavourited"
result = run(cli.statuses.unfavourite, status["id"])
assert result.exit_code == 0
assert result.stdout.strip() == "✓ Status unfavourited"
# A short delay is required before the server returns new data
time.sleep(0.1)
time.sleep(0.2)
status = api.fetch_status(app, user, status["id"]).json()
assert not status["favourited"]
@ -51,15 +57,17 @@ def test_favourite_json(app, user, run):
status = api.post_status(app, user, "foo").json()
assert not status["favourited"]
out = run("favourite", status["id"], "--json")
result = json.loads(out)
result = run(cli.statuses.favourite, status["id"], "--json")
assert result.exit_code == 0
result = json.loads(result.stdout)
assert result["id"] == status["id"]
assert result["favourited"] is True
out = run("unfavourite", status["id"], "--json")
result = json.loads(out)
result = run(cli.statuses.unfavourite, status["id"], "--json")
assert result.exit_code == 0
result = json.loads(result.stdout)
assert result["id"] == status["id"]
assert result["favourited"] is False
@ -68,17 +76,24 @@ def test_reblog(app, user, run):
status = api.post_status(app, user, "foo").json()
assert not status["reblogged"]
out = run("reblog", status["id"])
assert out == "✓ Status reblogged"
result = run(cli.statuses.reblogged_by, status["id"])
assert result.exit_code == 0
assert result.stdout.strip() == "This status is not reblogged by anyone"
result = run(cli.statuses.reblog, status["id"])
assert result.exit_code == 0
assert result.stdout.strip() == "✓ Status reblogged"
status = api.fetch_status(app, user, status["id"]).json()
assert status["reblogged"]
out = run("reblogged_by", status["id"])
assert user.username in out
result = run(cli.statuses.reblogged_by, status["id"])
assert result.exit_code == 0
assert user.username in result.stdout
out = run("unreblog", status["id"])
assert out == "✓ Status unreblogged"
result = run(cli.statuses.unreblog, status["id"])
assert result.exit_code == 0
assert result.stdout.strip() == "✓ Status unreblogged"
status = api.fetch_status(app, user, status["id"]).json()
assert not status["reblogged"]
@ -88,19 +103,23 @@ def test_reblog_json(app, user, run):
status = api.post_status(app, user, "foo").json()
assert not status["reblogged"]
out = run("reblog", status["id"], "--json")
result = json.loads(out)
result = run(cli.statuses.reblog, status["id"], "--json")
assert result.exit_code == 0
result = json.loads(result.stdout)
assert result["reblogged"] is True
assert result["reblog"]["id"] == status["id"]
out = run("reblogged_by", status["id"], "--json")
[reblog] = json.loads(out)
result = run(cli.statuses.reblogged_by, status["id"], "--json")
assert result.exit_code == 0
[reblog] = json.loads(result.stdout)
assert reblog["acct"] == user.username
out = run("unreblog", status["id"], "--json")
result = json.loads(out)
result = run(cli.statuses.unreblog, status["id"], "--json")
assert result.exit_code == 0
result = json.loads(result.stdout)
assert result["reblogged"] is False
assert result["reblog"] is None
@ -109,14 +128,16 @@ def test_pin(app, user, run):
status = api.post_status(app, user, "foo").json()
assert not status["pinned"]
out = run("pin", status["id"])
assert out == "✓ Status pinned"
result = run(cli.statuses.pin, status["id"])
assert result.exit_code == 0
assert result.stdout.strip() == "✓ Status pinned"
status = api.fetch_status(app, user, status["id"]).json()
assert status["pinned"]
out = run("unpin", status["id"])
assert out == "✓ Status unpinned"
result = run(cli.statuses.unpin, status["id"])
assert result.exit_code == 0
assert result.stdout.strip() == "✓ Status unpinned"
status = api.fetch_status(app, user, status["id"]).json()
assert not status["pinned"]
@ -126,15 +147,17 @@ def test_pin_json(app, user, run):
status = api.post_status(app, user, "foo").json()
assert not status["pinned"]
out = run("pin", status["id"], "--json")
result = json.loads(out)
result = run(cli.statuses.pin, status["id"], "--json")
assert result.exit_code == 0
result = json.loads(result.stdout)
assert result["pinned"] is True
assert result["id"] == status["id"]
out = run("unpin", status["id"], "--json")
result = json.loads(out)
result = run(cli.statuses.unpin, status["id"], "--json")
assert result.exit_code == 0
result = json.loads(result.stdout)
assert result["pinned"] is False
assert result["id"] == status["id"]
@ -143,14 +166,16 @@ def test_bookmark(app, user, run):
status = api.post_status(app, user, "foo").json()
assert not status["bookmarked"]
out = run("bookmark", status["id"])
assert out == "✓ Status bookmarked"
result = run(cli.statuses.bookmark, status["id"])
assert result.exit_code == 0
assert result.stdout.strip() == "✓ Status bookmarked"
status = api.fetch_status(app, user, status["id"]).json()
assert status["bookmarked"]
out = run("unbookmark", status["id"])
assert out == "✓ Status unbookmarked"
result = run(cli.statuses.unbookmark, status["id"])
assert result.exit_code == 0
assert result.stdout.strip() == "✓ Status unbookmarked"
status = api.fetch_status(app, user, status["id"]).json()
assert not status["bookmarked"]
@ -160,14 +185,16 @@ def test_bookmark_json(app, user, run):
status = api.post_status(app, user, "foo").json()
assert not status["bookmarked"]
out = run("bookmark", status["id"], "--json")
result = json.loads(out)
result = run(cli.statuses.bookmark, status["id"], "--json")
assert result.exit_code == 0
result = json.loads(result.stdout)
assert result["id"] == status["id"]
assert result["bookmarked"] is True
out = run("unbookmark", status["id"], "--json")
result = json.loads(out)
result = run(cli.statuses.unbookmark, status["id"], "--json")
assert result.exit_code == 0
result = json.loads(result.stdout)
assert result["id"] == status["id"]
assert result["bookmarked"] is False

View File

@ -0,0 +1,163 @@
import re
from typing import List
from toot import api, cli
from toot.entities import FeaturedTag, Tag, from_dict, from_dict_list
def test_tags(run):
result = run(cli.tags.tags, "followed")
assert result.exit_code == 0
assert result.stdout.strip() == "You're not following any hashtags"
result = run(cli.tags.tags, "follow", "foo")
assert result.exit_code == 0
assert result.stdout.strip() == "✓ You are now following #foo"
result = run(cli.tags.tags, "followed")
assert result.exit_code == 0
assert _find_tags(result.stdout) == ["#foo"]
result = run(cli.tags.tags, "follow", "bar")
assert result.exit_code == 0
assert result.stdout.strip() == "✓ You are now following #bar"
result = run(cli.tags.tags, "followed")
assert result.exit_code == 0
assert _find_tags(result.stdout) == ["#bar", "#foo"]
result = run(cli.tags.tags, "unfollow", "foo")
assert result.exit_code == 0
assert result.stdout.strip() == "✓ You are no longer following #foo"
result = run(cli.tags.tags, "followed")
assert result.exit_code == 0
assert _find_tags(result.stdout) == ["#bar"]
result = run(cli.tags.tags, "unfollow", "bar")
assert result.exit_code == 0
assert result.stdout.strip() == "✓ You are no longer following #bar"
result = run(cli.tags.tags, "followed")
assert result.exit_code == 0
assert result.stdout.strip() == "You're not following any hashtags"
def test_tags_json(run_json):
result = run_json(cli.tags.tags, "followed", "--json")
assert result == []
result = run_json(cli.tags.tags, "follow", "foo", "--json")
tag = from_dict(Tag, result)
assert tag.name == "foo"
assert tag.following is True
result = run_json(cli.tags.tags, "followed", "--json")
[tag] = from_dict_list(Tag, result)
assert tag.name == "foo"
assert tag.following is True
result = run_json(cli.tags.tags, "follow", "bar", "--json")
tag = from_dict(Tag, result)
assert tag.name == "bar"
assert tag.following is True
result = run_json(cli.tags.tags, "followed", "--json")
tags = from_dict_list(Tag, result)
[bar, foo] = sorted(tags, key=lambda t: t.name)
assert foo.name == "foo"
assert foo.following is True
assert bar.name == "bar"
assert bar.following is True
result = run_json(cli.tags.tags, "unfollow", "foo", "--json")
tag = from_dict(Tag, result)
assert tag.name == "foo"
assert tag.following is False
result = run_json(cli.tags.tags, "unfollow", "bar", "--json")
tag = from_dict(Tag, result)
assert tag.name == "bar"
assert tag.following is False
result = run_json(cli.tags.tags, "followed", "--json")
assert result == []
def test_tags_featured(run, app, user):
result = run(cli.tags.tags, "featured")
assert result.exit_code == 0
assert result.stdout.strip() == "You don't have any featured hashtags"
result = run(cli.tags.tags, "feature", "foo")
assert result.exit_code == 0
assert result.stdout.strip() == "✓ Tag #foo is now featured"
result = run(cli.tags.tags, "featured")
assert result.exit_code == 0
assert _find_tags(result.stdout) == ["#foo"]
result = run(cli.tags.tags, "feature", "bar")
assert result.exit_code == 0
assert result.stdout.strip() == "✓ Tag #bar is now featured"
result = run(cli.tags.tags, "featured")
assert result.exit_code == 0
assert _find_tags(result.stdout) == ["#bar", "#foo"]
# Unfeature by Name
result = run(cli.tags.tags, "unfeature", "foo")
assert result.exit_code == 0
assert result.stdout.strip() == "✓ Tag #foo is no longer featured"
result = run(cli.tags.tags, "featured")
assert result.exit_code == 0
assert _find_tags(result.stdout) == ["#bar"]
# Unfeature by ID
tag = api.find_featured_tag(app, user, "bar")
assert tag is not None
result = run(cli.tags.tags, "unfeature", tag["id"])
assert result.exit_code == 0
assert result.stdout.strip() == "✓ Tag #bar is no longer featured"
result = run(cli.tags.tags, "featured")
assert result.exit_code == 0
assert result.stdout.strip() == "You don't have any featured hashtags"
def test_tags_featured_json(run_json):
result = run_json(cli.tags.tags, "featured", "--json")
assert result == []
result = run_json(cli.tags.tags, "feature", "foo", "--json")
tag = from_dict(FeaturedTag, result)
assert tag.name == "foo"
result = run_json(cli.tags.tags, "featured", "--json")
[tag] = from_dict_list(FeaturedTag, result)
assert tag.name == "foo"
result = run_json(cli.tags.tags, "feature", "bar", "--json")
tag = from_dict(FeaturedTag, result)
assert tag.name == "bar"
result = run_json(cli.tags.tags, "featured", "--json")
tags = from_dict_list(FeaturedTag, result)
[bar, foo] = sorted(tags, key=lambda t: t.name)
assert foo.name == "foo"
assert bar.name == "bar"
result = run_json(cli.tags.tags, "unfeature", "foo", "--json")
assert result == {}
result = run_json(cli.tags.tags, "unfeature", "bar", "--json")
assert result == {}
result = run_json(cli.tags.tags, "featured", "--json")
assert result == []
def _find_tags(txt: str) -> List[str]:
return sorted(re.findall(r"#\w+", txt))

View File

@ -0,0 +1,198 @@
import pytest
from time import sleep
from uuid import uuid4
from toot import api, cli
from toot.entities import from_dict, Status
from tests.integration.conftest import TOOT_TEST_BASE_URL, register_account
# TODO: If fixture is not overriden here, tests fail, not sure why, figure it out
@pytest.fixture(scope="module")
def user(app):
return register_account(app)
@pytest.fixture(scope="module")
def other_user(app):
return register_account(app)
@pytest.fixture(scope="module")
def friend_user(app, user):
friend = register_account(app)
friend_account = api.find_account(app, user, friend.username)
api.follow(app, user, friend_account["id"])
return friend
@pytest.fixture(scope="module")
def friend_list(app, user, friend_user):
friend_account = api.find_account(app, user, friend_user.username)
list = api.create_list(app, user, str(uuid4()))
api.add_accounts_to_list(app, user, list["id"], account_ids=[friend_account["id"]])
return list
def test_timelines(app, user, other_user, friend_user, friend_list, run):
status1 = _post_status(app, user, "#foo")
status2 = _post_status(app, other_user, "#bar")
status3 = _post_status(app, friend_user, "#foo #bar")
# Give mastodon time to process things :/
# Tests fail if this is removed, required delay depends on server speed
sleep(1)
# Home timeline
result = run(cli.timelines.timeline)
assert result.exit_code == 0
assert status1.id in result.stdout
assert status2.id not in result.stdout
assert status3.id in result.stdout
# Public timeline
result = run(cli.timelines.timeline, "--public")
assert result.exit_code == 0
assert status1.id in result.stdout
assert status2.id in result.stdout
assert status3.id in result.stdout
# Anon public timeline
result = run(cli.timelines.timeline, "--instance", TOOT_TEST_BASE_URL, "--public")
assert result.exit_code == 0
assert status1.id in result.stdout
assert status2.id in result.stdout
assert status3.id in result.stdout
# Tag timeline
result = run(cli.timelines.timeline, "--tag", "foo")
assert result.exit_code == 0
assert status1.id in result.stdout
assert status2.id not in result.stdout
assert status3.id in result.stdout
result = run(cli.timelines.timeline, "--tag", "bar")
assert result.exit_code == 0
assert status1.id not in result.stdout
assert status2.id in result.stdout
assert status3.id in result.stdout
# Anon tag timeline
result = run(cli.timelines.timeline, "--instance", TOOT_TEST_BASE_URL, "--tag", "foo")
assert result.exit_code == 0
assert status1.id in result.stdout
assert status2.id not in result.stdout
assert status3.id in result.stdout
# List timeline (by list name)
result = run(cli.timelines.timeline, "--list", friend_list["title"])
assert result.exit_code == 0
assert status1.id not in result.stdout
assert status2.id not in result.stdout
assert status3.id in result.stdout
# List timeline (by list ID)
result = run(cli.timelines.timeline, "--list", friend_list["id"])
assert result.exit_code == 0
assert status1.id not in result.stdout
assert status2.id not in result.stdout
assert status3.id in result.stdout
# Account timeline
result = run(cli.timelines.timeline, "--account", friend_user.username)
assert result.exit_code == 0
assert status1.id not in result.stdout
assert status2.id not in result.stdout
assert status3.id in result.stdout
result = run(cli.timelines.timeline, "--account", other_user.username)
assert result.exit_code == 0
assert status1.id not in result.stdout
assert status2.id in result.stdout
assert status3.id not in result.stdout
def test_empty_timeline(app, run_as):
user = register_account(app)
result = run_as(user, cli.timelines.timeline)
assert result.exit_code == 0
assert result.stdout.strip() == "" * 80
def test_timeline_cant_combine_timelines(run):
result = run(cli.timelines.timeline, "--tag", "foo", "--account", "bar")
assert result.exit_code == 1
assert result.stderr.strip() == "Error: Only one of --public, --tag, --account, or --list can be used at one time."
def test_timeline_local_needs_public_or_tag(run):
result = run(cli.timelines.timeline, "--local")
assert result.exit_code == 1
assert result.stderr.strip() == "Error: The --local option is only valid alongside --public or --tag."
def test_timeline_instance_needs_public_or_tag(run):
result = run(cli.timelines.timeline, "--instance", TOOT_TEST_BASE_URL)
assert result.exit_code == 1
assert result.stderr.strip() == "Error: The --instance option is only valid alongside --public or --tag."
def test_bookmarks(app, user, run):
status1 = _post_status(app, user)
status2 = _post_status(app, user)
api.bookmark(app, user, status1.id)
api.bookmark(app, user, status2.id)
result = run(cli.timelines.bookmarks)
assert result.exit_code == 0
assert status1.id in result.stdout
assert status2.id in result.stdout
assert result.stdout.find(status1.id) > result.stdout.find(status2.id)
result = run(cli.timelines.bookmarks, "--reverse")
assert result.exit_code == 0
assert status1.id in result.stdout
assert status2.id in result.stdout
assert result.stdout.find(status1.id) < result.stdout.find(status2.id)
def test_notifications(app, user, other_user, run):
result = run(cli.timelines.notifications)
assert result.exit_code == 0
assert result.stdout.strip() == "You have no notifications"
text = f"Paging doctor @{user.username}"
status = _post_status(app, other_user, text)
sleep(0.5) # grr
result = run(cli.timelines.notifications)
assert result.exit_code == 0
assert f"@{other_user.username} mentioned you" in result.stdout
assert status.id in result.stdout
assert text in result.stdout
result = run(cli.timelines.notifications, "--mentions")
assert result.exit_code == 0
assert f"@{other_user.username} mentioned you" in result.stdout
assert status.id in result.stdout
assert text in result.stdout
def test_notifications_follow(app, user, friend_user, run_as):
result = run_as(friend_user, cli.timelines.notifications)
assert result.exit_code == 0
assert f"@{user.username} now follows you" in result.stdout
result = run_as(friend_user, cli.timelines.notifications, "--mentions")
assert result.exit_code == 0
assert "now follows you" not in result.stdout
def _post_status(app, user, text=None) -> Status:
text = text or str(uuid4())
response = api.post_status(app, user, text)
return from_dict(Status, response.json())

View File

@ -0,0 +1,149 @@
from uuid import uuid4
from tests.integration.conftest import TRUMPET
from toot import api, cli
from toot.entities import Account, from_dict
from toot.utils import get_text
def test_update_account_no_options(run):
result = run(cli.accounts.update_account)
assert result.exit_code == 1
assert result.stderr.strip() == "Error: Please specify at least one option to update the account"
def test_update_account_display_name(run, app, user):
name = str(uuid4())[:10]
result = run(cli.accounts.update_account, "--display-name", name)
assert result.exit_code == 0
assert result.stdout.strip() == "✓ Account updated"
account = api.verify_credentials(app, user).json()
assert account["display_name"] == name
def test_update_account_json(run_json, app, user):
name = str(uuid4())[:10]
out = run_json(cli.accounts.update_account, "--display-name", name, "--json")
account = from_dict(Account, out)
assert account.acct == user.username
assert account.display_name == name
def test_update_account_note(run, app, user):
note = ("It's 106 miles to Chicago, we got a full tank of gas, half a pack "
"of cigarettes, it's dark... and we're wearing sunglasses.")
result = run(cli.accounts.update_account, "--note", note)
assert result.exit_code == 0
assert result.stdout.strip() == "✓ Account updated"
account = api.verify_credentials(app, user).json()
assert get_text(account["note"]) == note
def test_update_account_language(run, app, user):
result = run(cli.accounts.update_account, "--language", "hr")
assert result.exit_code == 0
assert result.stdout.strip() == "✓ Account updated"
account = api.verify_credentials(app, user).json()
assert account["source"]["language"] == "hr"
def test_update_account_privacy(run, app, user):
result = run(cli.accounts.update_account, "--privacy", "private")
assert result.exit_code == 0
assert result.stdout.strip() == "✓ Account updated"
account = api.verify_credentials(app, user).json()
assert account["source"]["privacy"] == "private"
def test_update_account_avatar(run, app, user):
account = api.verify_credentials(app, user).json()
old_value = account["avatar"]
result = run(cli.accounts.update_account, "--avatar", TRUMPET)
assert result.exit_code == 0
assert result.stdout.strip() == "✓ Account updated"
account = api.verify_credentials(app, user).json()
assert account["avatar"] != old_value
def test_update_account_header(run, app, user):
account = api.verify_credentials(app, user).json()
old_value = account["header"]
result = run(cli.accounts.update_account, "--header", TRUMPET)
assert result.exit_code == 0
assert result.stdout.strip() == "✓ Account updated"
account = api.verify_credentials(app, user).json()
assert account["header"] != old_value
def test_update_account_locked(run, app, user):
result = run(cli.accounts.update_account, "--locked")
assert result.exit_code == 0
assert result.stdout.strip() == "✓ Account updated"
account = api.verify_credentials(app, user).json()
assert account["locked"] is True
result = run(cli.accounts.update_account, "--no-locked")
assert result.exit_code == 0
assert result.stdout.strip() == "✓ Account updated"
account = api.verify_credentials(app, user).json()
assert account["locked"] is False
def test_update_account_bot(run, app, user):
result = run(cli.accounts.update_account, "--bot")
assert result.exit_code == 0
assert result.stdout.strip() == "✓ Account updated"
account = api.verify_credentials(app, user).json()
assert account["bot"] is True
result = run(cli.accounts.update_account, "--no-bot")
assert result.exit_code == 0
assert result.stdout.strip() == "✓ Account updated"
account = api.verify_credentials(app, user).json()
assert account["bot"] is False
def test_update_account_discoverable(run, app, user):
result = run(cli.accounts.update_account, "--discoverable")
assert result.exit_code == 0
assert result.stdout.strip() == "✓ Account updated"
account = api.verify_credentials(app, user).json()
assert account["discoverable"] is True
result = run(cli.accounts.update_account, "--no-discoverable")
assert result.exit_code == 0
assert result.stdout.strip() == "✓ Account updated"
account = api.verify_credentials(app, user).json()
assert account["discoverable"] is False
def test_update_account_sensitive(run, app, user):
result = run(cli.accounts.update_account, "--sensitive")
assert result.exit_code == 0
assert result.stdout.strip() == "✓ Account updated"
account = api.verify_credentials(app, user).json()
assert account["source"]["sensitive"] is True
result = run(cli.accounts.update_account, "--no-sensitive")
assert result.exit_code == 0
assert result.stdout.strip() == "✓ Account updated"
account = api.verify_credentials(app, user).json()
assert account["source"]["sensitive"] is False

View File

@ -1,72 +0,0 @@
import pytest
from unittest import mock
from toot import App, CLIENT_NAME, CLIENT_WEBSITE
from toot.api import create_app, login, SCOPES, AuthenticationError
from tests.utils import MockResponse
@mock.patch('toot.http.anon_post')
def test_create_app(mock_post):
mock_post.return_value = MockResponse({
'client_id': 'foo',
'client_secret': 'bar',
})
create_app('https://bigfish.software')
mock_post.assert_called_once_with('https://bigfish.software/api/v1/apps', json={
'website': CLIENT_WEBSITE,
'client_name': CLIENT_NAME,
'scopes': SCOPES,
'redirect_uris': 'urn:ietf:wg:oauth:2.0:oob',
})
@mock.patch('toot.http.anon_post')
def test_login(mock_post):
app = App('bigfish.software', 'https://bigfish.software', 'foo', 'bar')
data = {
'grant_type': 'password',
'client_id': app.client_id,
'client_secret': app.client_secret,
'username': 'user',
'password': 'pass',
'scope': SCOPES,
}
mock_post.return_value = MockResponse({
'token_type': 'bearer',
'scope': 'read write follow',
'access_token': 'xxx',
'created_at': 1492523699
})
login(app, 'user', 'pass')
mock_post.assert_called_once_with(
'https://bigfish.software/oauth/token', data=data, allow_redirects=False)
@mock.patch('toot.http.anon_post')
def test_login_failed(mock_post):
app = App('bigfish.software', 'https://bigfish.software', 'foo', 'bar')
data = {
'grant_type': 'password',
'client_id': app.client_id,
'client_secret': app.client_secret,
'username': 'user',
'password': 'pass',
'scope': SCOPES,
}
mock_post.return_value = MockResponse(is_redirect=True)
with pytest.raises(AuthenticationError):
login(app, 'user', 'pass')
mock_post.assert_called_once_with(
'https://bigfish.software/oauth/token', data=data, allow_redirects=False)

View File

@ -1,449 +0,0 @@
import io
import pytest
import re
from collections import namedtuple
from unittest import mock
from toot import console, User, App, http
from toot.exceptions import ConsoleError
from tests.utils import MockResponse
app = App('habunek.com', 'https://habunek.com', 'foo', 'bar')
user = User('habunek.com', 'ivan@habunek.com', 'xxx')
MockUuid = namedtuple("MockUuid", ["hex"])
def uncolorize(text):
"""Remove ANSI color sequences from a string"""
return re.sub(r'\x1b[^m]*m', '', text)
def test_print_usage(capsys):
console.print_usage()
out, err = capsys.readouterr()
assert "toot - a Mastodon CLI client" in out
@mock.patch('uuid.uuid4')
@mock.patch('toot.http.post')
def test_post_defaults(mock_post, mock_uuid, capsys):
mock_uuid.return_value = MockUuid("rock-on")
mock_post.return_value = MockResponse({
'url': 'https://habunek.com/@ihabunek/1234567890'
})
console.run_command(app, user, 'post', ['Hello world'])
mock_post.assert_called_once_with(app, user, '/api/v1/statuses', json={
'status': 'Hello world',
'visibility': 'public',
'media_ids': [],
'sensitive': False,
}, headers={"Idempotency-Key": "rock-on"})
out, err = capsys.readouterr()
assert 'Toot posted' in out
assert 'https://habunek.com/@ihabunek/1234567890' in out
assert not err
@mock.patch('uuid.uuid4')
@mock.patch('toot.http.post')
def test_post_with_options(mock_post, mock_uuid, capsys):
mock_uuid.return_value = MockUuid("up-the-irons")
args = [
'Hello world',
'--visibility', 'unlisted',
'--sensitive',
'--spoiler-text', 'Spoiler!',
'--reply-to', '123a',
'--language', 'hr',
]
mock_post.return_value = MockResponse({
'url': 'https://habunek.com/@ihabunek/1234567890'
})
console.run_command(app, user, 'post', args)
mock_post.assert_called_once_with(app, user, '/api/v1/statuses', json={
'status': 'Hello world',
'media_ids': [],
'visibility': 'unlisted',
'sensitive': True,
'spoiler_text': "Spoiler!",
'in_reply_to_id': '123a',
'language': 'hr',
}, headers={"Idempotency-Key": "up-the-irons"})
out, err = capsys.readouterr()
assert 'Toot posted' in out
assert 'https://habunek.com/@ihabunek/1234567890' in out
assert not err
def test_post_invalid_visibility(capsys):
args = ['Hello world', '--visibility', 'foo']
with pytest.raises(SystemExit):
console.run_command(app, user, 'post', args)
out, err = capsys.readouterr()
assert "invalid visibility value: 'foo'" in err
def test_post_invalid_media(capsys):
args = ['Hello world', '--media', 'does_not_exist.jpg']
with pytest.raises(SystemExit):
console.run_command(app, user, 'post', args)
out, err = capsys.readouterr()
assert "can't open 'does_not_exist.jpg'" in err
@mock.patch('toot.http.delete')
def test_delete(mock_delete, capsys):
console.run_command(app, user, 'delete', ['12321'])
mock_delete.assert_called_once_with(app, user, '/api/v1/statuses/12321')
out, err = capsys.readouterr()
assert 'Status deleted' in out
assert not err
@mock.patch('toot.http.get')
def test_timeline(mock_get, monkeypatch, capsys):
mock_get.return_value = MockResponse([{
'id': '111111111111111111',
'account': {
'display_name': 'Frank Zappa 🎸',
'last_status_at': '2017-04-12T15:53:18.174Z',
'acct': 'fz'
},
'created_at': '2017-04-12T15:53:18.174Z',
'content': "<p>The computer can&apos;t tell you the emotional story. It can give you the exact mathematical design, but what's missing is the eyebrows.</p>",
'reblog': None,
'in_reply_to_id': None,
'media_attachments': [],
}])
console.run_command(app, user, 'timeline', ['--once'])
mock_get.assert_called_once_with(app, user, '/api/v1/timelines/home', {'limit': 10})
out, err = capsys.readouterr()
lines = out.split("\n")
assert "Frank Zappa 🎸" in lines[1]
assert "@fz" in lines[1]
assert "2017-04-12 15:53 UTC" in lines[1]
assert (
"The computer can't tell you the emotional story. It can give you the "
"exact mathematical design, but\nwhat's missing is the eyebrows." in out)
assert "111111111111111111" in lines[-3]
assert err == ""
@mock.patch('toot.http.get')
def test_timeline_with_re(mock_get, monkeypatch, capsys):
mock_get.return_value = MockResponse([{
'id': '111111111111111111',
'created_at': '2017-04-12T15:53:18.174Z',
'account': {
'display_name': 'Frank Zappa',
'acct': 'fz'
},
'reblog': {
'created_at': '2017-04-12T15:53:18.174Z',
'account': {
'display_name': 'Johnny Cash',
'last_status_at': '2011-04-12',
'acct': 'jc'
},
'content': "<p>The computer can&apos;t tell you the emotional story. It can give you the exact mathematical design, but what's missing is the eyebrows.</p>",
'media_attachments': [],
},
'in_reply_to_id': '111111111111111110',
'media_attachments': [],
}])
console.run_command(app, user, 'timeline', ['--once'])
mock_get.assert_called_once_with(app, user, '/api/v1/timelines/home', {'limit': 10})
out, err = capsys.readouterr()
lines = uncolorize(out).split("\n")
assert "Johnny Cash" in lines[1]
assert "@jc" in lines[1]
assert "2017-04-12 15:53 UTC" in lines[1]
assert (
"The computer can't tell you the emotional story. It can give you the "
"exact mathematical design, but\nwhat's missing is the eyebrows." in out)
assert "111111111111111111" in lines[-3]
assert "↻ @fz boosted" in lines[-3]
assert err == ""
@mock.patch('toot.http.post')
def test_upload(mock_post, capsys):
mock_post.return_value = MockResponse({
'id': 123,
'preview_url': 'https://bigfish.software/789/012',
'url': 'https://bigfish.software/345/678',
'type': 'image',
})
console.run_command(app, user, 'upload', [__file__])
assert mock_post.call_count == 1
args, kwargs = http.post.call_args
assert args == (app, user, '/api/v2/media')
assert isinstance(kwargs['files']['file'], io.BufferedReader)
out, err = capsys.readouterr()
assert "Uploading media" in out
assert __file__ in out
@mock.patch('toot.http.get')
def test_whoami(mock_get, capsys):
mock_get.return_value = MockResponse({
'acct': 'ihabunek',
'avatar': 'https://files.mastodon.social/accounts/avatars/000/046/103/original/6a1304e135cac514.jpg?1491312434',
'avatar_static': 'https://files.mastodon.social/accounts/avatars/000/046/103/original/6a1304e135cac514.jpg?1491312434',
'created_at': '2017-04-04T13:23:09.777Z',
'display_name': 'Ivan Habunek',
'followers_count': 5,
'following_count': 9,
'header': '/headers/original/missing.png',
'header_static': '/headers/original/missing.png',
'id': 46103,
'locked': False,
'note': 'A developer.',
'statuses_count': 19,
'url': 'https://mastodon.social/@ihabunek',
'username': 'ihabunek',
'fields': []
})
console.run_command(app, user, 'whoami', [])
mock_get.assert_called_once_with(app, user, '/api/v1/accounts/verify_credentials')
out, err = capsys.readouterr()
out = uncolorize(out)
assert "@ihabunek Ivan Habunek" in out
assert "A developer." in out
assert "https://mastodon.social/@ihabunek" in out
assert "ID: 46103" in out
assert "Since: 2017-04-04" in out
assert "Followers: 5" in out
assert "Following: 9" in out
assert "Statuses: 19" in out
@mock.patch('toot.http.get')
def test_notifications(mock_get, capsys):
mock_get.return_value = MockResponse([{
'id': '1',
'type': 'follow',
'created_at': '2019-02-16T07:01:20.714Z',
'account': {
'display_name': 'Frank Zappa',
'acct': 'frank@zappa.social',
},
}, {
'id': '2',
'type': 'mention',
'created_at': '2017-01-12T12:12:12.0Z',
'account': {
'display_name': 'Dweezil Zappa',
'acct': 'dweezil@zappa.social',
},
'status': {
'id': '111111111111111111',
'account': {
'display_name': 'Dweezil Zappa',
'acct': 'dweezil@zappa.social',
},
'created_at': '2017-04-12T15:53:18.174Z',
'content': "<p>We still have fans in 2017 @fan123</p>",
'reblog': None,
'in_reply_to_id': None,
'media_attachments': [],
},
}, {
'id': '3',
'type': 'reblog',
'created_at': '1983-11-03T03:03:03.333Z',
'account': {
'display_name': 'Terry Bozzio',
'acct': 'terry@bozzio.social',
},
'status': {
'id': '1234',
'account': {
'display_name': 'Zappa Fan',
'acct': 'fan123@zappa-fans.social'
},
'created_at': '1983-11-04T15:53:18.174Z',
'content': "<p>The Black Page, a masterpiece</p>",
'reblog': None,
'in_reply_to_id': None,
'media_attachments': [],
},
}, {
'id': '4',
'type': 'favourite',
'created_at': '1983-12-13T01:02:03.444Z',
'account': {
'display_name': 'Zappa Old Fan',
'acct': 'fan9@zappa-fans.social',
},
'status': {
'id': '1234',
'account': {
'display_name': 'Zappa Fan',
'acct': 'fan123@zappa-fans.social'
},
'created_at': '1983-11-04T15:53:18.174Z',
'content': "<p>The Black Page, a masterpiece</p>",
'reblog': None,
'in_reply_to_id': None,
'media_attachments': [],
},
}])
console.run_command(app, user, 'notifications', [])
mock_get.assert_called_once_with(app, user, '/api/v1/notifications', {'exclude_types[]': [], 'limit': 20})
out, err = capsys.readouterr()
out = uncolorize(out)
assert not err
assert out == "\n".join([
"────────────────────────────────────────────────────────────────────────────────────────────────────",
"Frank Zappa @frank@zappa.social now follows you",
"────────────────────────────────────────────────────────────────────────────────────────────────────",
"Dweezil Zappa @dweezil@zappa.social mentioned you in",
"Dweezil Zappa @dweezil@zappa.social 2017-04-12 15:53 UTC",
"",
"We still have fans in 2017 @fan123",
"",
"ID 111111111111111111 ",
"────────────────────────────────────────────────────────────────────────────────────────────────────",
"Terry Bozzio @terry@bozzio.social reblogged your status",
"Zappa Fan @fan123@zappa-fans.social 1983-11-04 15:53 UTC",
"",
"The Black Page, a masterpiece",
"",
"ID 1234 ",
"────────────────────────────────────────────────────────────────────────────────────────────────────",
"Zappa Old Fan @fan9@zappa-fans.social favourited your status",
"Zappa Fan @fan123@zappa-fans.social 1983-11-04 15:53 UTC",
"",
"The Black Page, a masterpiece",
"",
"ID 1234 ",
"────────────────────────────────────────────────────────────────────────────────────────────────────",
"",
])
@mock.patch('toot.http.get')
def test_notifications_empty(mock_get, capsys):
mock_get.return_value = MockResponse([])
console.run_command(app, user, 'notifications', [])
mock_get.assert_called_once_with(app, user, '/api/v1/notifications', {'exclude_types[]': [], 'limit': 20})
out, err = capsys.readouterr()
out = uncolorize(out)
assert not err
assert out == "No notification\n"
@mock.patch('toot.http.post')
def test_notifications_clear(mock_post, capsys):
console.run_command(app, user, 'notifications', ['--clear'])
out, err = capsys.readouterr()
out = uncolorize(out)
mock_post.assert_called_once_with(app, user, '/api/v1/notifications/clear')
assert not err
assert out == 'Cleared notifications\n'
def u(user_id, access_token="abc"):
username, instance = user_id.split("@")
return {
"instance": instance,
"username": username,
"access_token": access_token,
}
@mock.patch('toot.config.save_config')
@mock.patch('toot.config.load_config')
def test_logout(mock_load, mock_save, capsys):
mock_load.return_value = {
"users": {
"king@gizzard.social": u("king@gizzard.social"),
"lizard@wizard.social": u("lizard@wizard.social"),
},
"active_user": "king@gizzard.social",
}
console.run_command(app, user, "logout", ["king@gizzard.social"])
mock_save.assert_called_once_with({
'users': {
'lizard@wizard.social': u("lizard@wizard.social")
},
'active_user': None
})
out, err = capsys.readouterr()
assert "✓ User king@gizzard.social logged out" in out
@mock.patch('toot.config.save_config')
@mock.patch('toot.config.load_config')
def test_activate(mock_load, mock_save, capsys):
mock_load.return_value = {
"users": {
"king@gizzard.social": u("king@gizzard.social"),
"lizard@wizard.social": u("lizard@wizard.social"),
},
"active_user": "king@gizzard.social",
}
console.run_command(app, user, "activate", ["lizard@wizard.social"])
mock_save.assert_called_once_with({
'users': {
"king@gizzard.social": u("king@gizzard.social"),
'lizard@wizard.social': u("lizard@wizard.social")
},
'active_user': "lizard@wizard.social"
})
out, err = capsys.readouterr()
assert "✓ User lizard@wizard.social active" in out

View File

@ -1,26 +0,0 @@
from toot.output import colorize, strip_tags, STYLES
reset = STYLES["reset"]
red = STYLES["red"]
green = STYLES["green"]
bold = STYLES["bold"]
def test_colorize():
assert colorize("foo") == "foo"
assert colorize("<red>foo</red>") == f"{red}foo{reset}{reset}"
assert colorize("foo <red>bar</red> baz") == f"foo {red}bar{reset} baz{reset}"
assert colorize("foo <red bold>bar</red bold> baz") == f"foo {red}{bold}bar{reset} baz{reset}"
assert colorize("foo <red bold>bar</red> baz") == f"foo {red}{bold}bar{reset}{bold} baz{reset}"
assert colorize("foo <red bold>bar</> baz") == f"foo {red}{bold}bar{reset} baz{reset}"
assert colorize("<red>foo<bold>bar</bold>baz</red>") == f"{red}foo{bold}bar{reset}{red}baz{reset}{reset}"
def test_strip_tags():
assert strip_tags("foo") == "foo"
assert strip_tags("<red>foo</red>") == "foo"
assert strip_tags("foo <red>bar</red> baz") == "foo bar baz"
assert strip_tags("foo <red bold>bar</red bold> baz") == "foo bar baz"
assert strip_tags("foo <red bold>bar</red> baz") == "foo bar baz"
assert strip_tags("foo <red bold>bar</> baz") == "foo bar baz"
assert strip_tags("<red>foo<bold>bar</bold>baz</red>") == "foobarbaz"

View File

@ -1,7 +1,7 @@
from argparse import ArgumentTypeError
import click
import pytest
from toot.console import duration
from toot.cli.validators import validate_duration
from toot.wcstring import wc_wrap, trunc, pad, fit_text
from toot.utils import urlencode_url
@ -163,6 +163,9 @@ def test_wc_wrap_indented():
def test_duration():
def duration(value):
return validate_duration(None, None, value)
# Long hand
assert duration("1 second") == 1
assert duration("1 seconds") == 1
@ -190,17 +193,17 @@ def test_duration():
assert duration("5d 10h 3m 1s") == 5 * 86400 + 10 * 3600 + 3 * 60 + 1
assert duration("5d10h3m1s") == 5 * 86400 + 10 * 3600 + 3 * 60 + 1
with pytest.raises(ArgumentTypeError):
with pytest.raises(click.BadParameter):
duration("")
with pytest.raises(ArgumentTypeError):
with pytest.raises(click.BadParameter):
duration("100")
# Wrong order
with pytest.raises(ArgumentTypeError):
with pytest.raises(click.BadParameter):
duration("1m1d")
with pytest.raises(ArgumentTypeError):
with pytest.raises(click.BadParameter):
duration("banana")

View File

@ -2,12 +2,23 @@ import os
import sys
from os.path import join, expanduser
from collections import namedtuple
from typing import NamedTuple
__version__ = '0.39.0'
__version__ = '0.40.0'
class App(NamedTuple):
instance: str
base_url: str
client_id: str
client_secret: str
class User(NamedTuple):
instance: str
username: str
access_token: str
App = namedtuple('App', ['instance', 'base_url', 'client_id', 'client_secret'])
User = namedtuple('User', ['instance', 'username', 'access_token'])
DEFAULT_INSTANCE = 'https://mastodon.social'

View File

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

View File

@ -8,7 +8,7 @@ from typing import BinaryIO, List, Optional
from urllib.parse import urlparse, urlencode, quote
from toot import App, User, http, CLIENT_NAME, CLIENT_WEBSITE
from toot.exceptions import AuthenticationError, ConsoleError
from toot.exceptions import ConsoleError
from toot.utils import drop_empty_values, str_bool, str_bool_nullable
@ -48,9 +48,9 @@ def _status_action(app, user, status_id, action, data=None) -> Response:
return http.post(app, user, url, data=data)
def _tag_action(app, user, tag_name, action):
def _tag_action(app, user, tag_name, action) -> Response:
url = f"/api/v1/tags/{tag_name}/{action}"
return http.post(app, user, url).json()
return http.post(app, user, url)
def create_app(base_url):
@ -140,7 +140,7 @@ def fetch_app_token(app):
return http.anon_post(f"{app.base_url}/oauth/token", json=json).json()
def login(app, username, password):
def login(app: App, username: str, password: str):
url = app.base_url + '/oauth/token'
data = {
@ -152,16 +152,10 @@ def login(app, username, password):
'scope': SCOPES,
}
response = http.anon_post(url, data=data, allow_redirects=False)
# If auth fails, it redirects to the login page
if response.is_redirect:
raise AuthenticationError()
return response.json()
return http.anon_post(url, data=data).json()
def get_browser_login_url(app):
def get_browser_login_url(app: App) -> str:
"""Returns the URL for manual log in via browser"""
return "{}/oauth/authorize/?{}".format(app.base_url, urlencode({
"response_type": "code",
@ -171,7 +165,7 @@ def get_browser_login_url(app):
}))
def request_access_token(app, authorization_code):
def request_access_token(app: App, authorization_code: str):
url = app.base_url + '/oauth/token'
data = {
@ -306,6 +300,35 @@ def reblogged_by(app, user, status_id) -> Response:
return http.get(app, user, url)
def get_timeline_generator(
app: Optional[App],
user: Optional[User],
base_url: Optional[str] = None,
account: Optional[str] = None,
list_id: Optional[str] = None,
tag: Optional[str] = None,
local: bool = False,
public: bool = False,
limit=20, # TODO
):
if public:
if base_url:
return anon_public_timeline_generator(base_url, local=local, limit=limit)
else:
return public_timeline_generator(app, user, local=local, limit=limit)
elif tag:
if base_url:
return anon_tag_timeline_generator(base_url, tag, limit=limit)
else:
return tag_timeline_generator(app, user, tag, local=local, limit=limit)
elif account:
return account_timeline_generator(app, user, account, limit=limit)
elif list_id:
return timeline_list_generator(app, user, list_id, limit=limit)
else:
return home_timeline_generator(app, user, limit=limit)
def _get_next_path(headers):
"""Given timeline response headers, returns the path to the next batch"""
links = headers.get('Link', '')
@ -315,6 +338,14 @@ def _get_next_path(headers):
return "?".join([parsed.path, parsed.query])
def _get_next_url(headers) -> Optional[str]:
"""Given timeline response headers, returns the url to the next batch"""
links = headers.get('Link', '')
match = re.match('<([^>]+)>; rel="next"', links)
if match:
return match.group(1)
def _timeline_generator(app, user, path, params=None):
while path:
response = http.get(app, user, path, params)
@ -375,7 +406,7 @@ def conversation_timeline_generator(app, user, limit=20):
return _conversation_timeline_generator(app, user, path, params)
def account_timeline_generator(app: App, user: User, account_name: str, replies=False, reblogs=False, limit=20):
def account_timeline_generator(app, user, account_name: str, replies=False, reblogs=False, limit=20):
account = find_account(app, user, account_name)
path = f"/api/v1/accounts/{account['id']}/statuses"
params = {"limit": limit, "exclude_replies": not replies, "exclude_reblogs": not reblogs}
@ -387,24 +418,23 @@ def timeline_list_generator(app, user, list_id, limit=20):
return _timeline_generator(app, user, path, {'limit': limit})
def _anon_timeline_generator(instance, path, params=None):
while path:
url = f"https://{instance}{path}"
def _anon_timeline_generator(url, params=None):
while url:
response = http.anon_get(url, params)
yield response.json()
path = _get_next_path(response.headers)
url = _get_next_url(response.headers)
def anon_public_timeline_generator(instance, local=False, limit=20):
path = '/api/v1/timelines/public'
params = {'local': str_bool(local), 'limit': limit}
return _anon_timeline_generator(instance, path, params)
def anon_public_timeline_generator(base_url, local=False, limit=20):
query = urlencode({"local": str_bool(local), "limit": limit})
url = f"{base_url}/api/v1/timelines/public?{query}"
return _anon_timeline_generator(url)
def anon_tag_timeline_generator(instance, hashtag, local=False, limit=20):
path = f"/api/v1/timelines/tag/{quote(hashtag)}"
params = {'local': str_bool(local), 'limit': limit}
return _anon_timeline_generator(instance, path, params)
def anon_tag_timeline_generator(base_url, hashtag, local=False, limit=20):
query = urlencode({"local": str_bool(local), "limit": limit})
url = f"{base_url}/api/v1/timelines/tag/{quote(hashtag)}?{query}"
return _anon_timeline_generator(url)
def get_media(app: App, user: User, id: str):
@ -427,7 +457,7 @@ def upload_media(
"thumbnail": _add_mime_type(thumbnail)
})
return http.post(app, user, "/api/v2/media", data=data, files=files).json()
return http.post(app, user, "/api/v2/media", data=data, files=files)
def _add_mime_type(file):
@ -469,11 +499,11 @@ def unfollow(app, user, account):
return _account_action(app, user, account, 'unfollow')
def follow_tag(app, user, tag_name):
def follow_tag(app, user, tag_name) -> Response:
return _tag_action(app, user, tag_name, 'follow')
def unfollow_tag(app, user, tag_name):
def unfollow_tag(app, user, tag_name) -> Response:
return _tag_action(app, user, tag_name, 'unfollow')
@ -501,6 +531,43 @@ def followed_tags(app, user):
return _get_response_list(app, user, path)
def featured_tags(app, user):
return http.get(app, user, "/api/v1/featured_tags")
def feature_tag(app, user, tag: str) -> Response:
return http.post(app, user, "/api/v1/featured_tags", data={"name": tag})
def unfeature_tag(app, user, tag_id: str) -> Response:
return http.delete(app, user, f"/api/v1/featured_tags/{tag_id}")
def find_tag(app, user, tag) -> Optional[dict]:
"""Find a hashtag by tag name or ID"""
tag = tag.lstrip("#")
results = search(app, user, tag, type="hashtags").json()
return next(
(
t for t in results["hashtags"]
if t["name"].lower() == tag.lstrip("#").lower() or t["id"] == tag
),
None
)
def find_featured_tag(app, user, tag) -> Optional[dict]:
"""Find a featured tag by tag name or ID"""
return next(
(
t for t in featured_tags(app, user).json()
if t["name"].lower() == tag.lstrip("#").lower() or t["id"] == tag
),
None
)
def whois(app, user, account):
return http.get(app, user, f'/api/v1/accounts/{account}').json()
@ -544,8 +611,8 @@ def verify_credentials(app, user) -> Response:
return http.get(app, user, '/api/v1/accounts/verify_credentials')
def get_notifications(app, user, exclude_types=[], limit=20):
params = {"exclude_types[]": exclude_types, "limit": limit}
def get_notifications(app, user, types=[], exclude_types=[], limit=20):
params = {"types[]": types, "exclude_types[]": exclude_types, "limit": limit}
return http.get(app, user, '/api/v1/notifications', params).json()
@ -559,16 +626,7 @@ def get_instance(base_url: str) -> Response:
def get_lists(app, user):
path = "/api/v1/lists"
return _get_response_list(app, user, path)
def find_list_id(app, user, title):
lists = get_lists(app, user)
for list_item in lists:
if list_item["title"] == title:
return list_item["id"]
return None
return http.get(app, user, "/api/v1/lists").json()
def get_list_accounts(app, user, list_id):
@ -576,7 +634,7 @@ def get_list_accounts(app, user, list_id):
return _get_response_list(app, user, path)
def create_list(app, user, title, replies_policy):
def create_list(app, user, title, replies_policy="none"):
url = "/api/v1/lists"
json = {'title': title}
if replies_policy:

View File

@ -1,18 +1,19 @@
import sys
import webbrowser
from builtins import input
from getpass import getpass
from toot import api, config, DEFAULT_INSTANCE, User, App
from toot import api, config, User, App
from toot.entities import from_dict, Instance
from toot.exceptions import ApiError, ConsoleError
from toot.output import print_out
from urllib.parse import urlparse
def register_app(domain, base_url):
def find_instance(base_url: str) -> Instance:
try:
instance = api.get_instance(base_url).json()
return from_dict(Instance, instance)
except Exception:
raise ConsoleError(f"Instance not found at {base_url}")
def register_app(domain: str, base_url: str) -> App:
try:
print_out("Registering application...")
response = api.create_app(base_url)
except ApiError:
raise ConsoleError("Registration failed.")
@ -20,114 +21,54 @@ def register_app(domain, base_url):
app = App(domain, base_url, response['client_id'], response['client_secret'])
config.save_app(app)
print_out("Application tokens saved.")
return app
def create_app_interactive(base_url):
if not base_url:
print_out(f"Enter instance URL [<green>{DEFAULT_INSTANCE}</green>]: ", end="")
base_url = input()
if not base_url:
base_url = DEFAULT_INSTANCE
domain = get_instance_domain(base_url)
def get_or_create_app(base_url: str) -> App:
instance = find_instance(base_url)
domain = _get_instance_domain(instance)
return config.load_app(domain) or register_app(domain, base_url)
def get_instance_domain(base_url):
print_out("Looking up instance info...")
instance = api.get_instance(base_url).json()
print_out(
f"Found instance <blue>{instance['title']}</blue> "
f"running Mastodon version <yellow>{instance['version']}</yellow>"
)
# Pleroma and its forks return an actual URI here, rather than a
# domain name like Mastodon. This is contrary to the spec.¯
# in that case, parse out the domain and return it.
parsed_uri = urlparse(instance["uri"])
if parsed_uri.netloc:
# Pleroma, Akkoma, GotoSocial, etc.
return parsed_uri.netloc
else:
# Others including Mastodon servers
return parsed_uri.path
# NB: when updating to v2 instance endpoint, this field has been renamed to `domain`
def create_user(app, access_token):
def create_user(app: App, access_token: str) -> User:
# Username is not yet known at this point, so fetch it from Mastodon
user = User(app.instance, None, access_token)
creds = api.verify_credentials(app, user).json()
user = User(app.instance, creds['username'], access_token)
user = User(app.instance, creds["username"], access_token)
config.save_user(user, activate=True)
print_out("Access token saved to config at: <green>{}</green>".format(
config.get_config_file_path()))
return user
def login_interactive(app, email=None):
print_out("Log in to <green>{}</green>".format(app.instance))
if email:
print_out("Email: <green>{}</green>".format(email))
while not email:
email = input('Email: ')
# Accept password piped from stdin, useful for testing purposes but not
# documented so people won't get ideas. Otherwise prompt for password.
if sys.stdin.isatty():
password = getpass('Password: ')
else:
password = sys.stdin.read().strip()
print_out("Password: <green>read from stdin</green>")
def login_username_password(app: App, email: str, password: str) -> User:
try:
print_out("Authenticating...")
response = api.login(app, email, password)
except ApiError:
except Exception:
raise ConsoleError("Login failed")
return create_user(app, response['access_token'])
return create_user(app, response["access_token"])
BROWSER_LOGIN_EXPLANATION = """
This authentication method requires you to log into your Mastodon instance
in your browser, where you will be asked to authorize <yellow>toot</yellow> to access
your account. When you do, you will be given an <yellow>authorization code</yellow>
which you need to paste here.
"""
def login_auth_code(app: App, authorization_code: str) -> User:
try:
response = api.request_access_token(app, authorization_code)
except Exception:
raise ConsoleError("Login failed")
return create_user(app, response["access_token"])
def login_browser_interactive(app):
url = api.get_browser_login_url(app)
print_out(BROWSER_LOGIN_EXPLANATION)
def _get_instance_domain(instance: Instance) -> str:
"""Extracts the instance domain name.
print_out("This is the login URL:")
print_out(url)
print_out("")
Pleroma and its forks return an actual URI here, rather than a domain name
like Mastodon. This is contrary to the spec.¯ in that case, parse out the
domain and return it.
yesno = input("Open link in default browser? [Y/n]")
if not yesno or yesno.lower() == 'y':
webbrowser.open(url)
authorization_code = ""
while not authorization_code:
authorization_code = input("Authorization code: ")
print_out("\nRequesting access token...")
response = api.request_access_token(app, authorization_code)
return create_user(app, response['access_token'])
TODO: when updating to v2 instance endpoint, this field has been renamed to
`domain`
"""
if instance.uri.startswith("http"):
return urlparse(instance.uri).netloc
return instance.uri

141
toot/cli/__init__.py Normal file
View File

@ -0,0 +1,141 @@
import click
import logging
import os
import sys
import typing as t
from click.testing import Result
from functools import wraps
from toot import App, User, config, __version__
from toot.settings import get_settings
if t.TYPE_CHECKING:
import typing_extensions as te
P = te.ParamSpec("P")
R = t.TypeVar("R")
T = t.TypeVar("T")
PRIVACY_CHOICES = ["public", "unlisted", "private"]
VISIBILITY_CHOICES = ["public", "unlisted", "private", "direct"]
TUI_COLORS = {
"1": 1,
"16": 16,
"88": 88,
"256": 256,
"16777216": 16777216,
"24bit": 16777216,
}
TUI_COLORS_CHOICES = list(TUI_COLORS.keys())
TUI_COLORS_VALUES = list(TUI_COLORS.values())
DURATION_EXAMPLES = """e.g. "1 day", "2 hours 30 minutes", "5 minutes 30
seconds" or any combination of above. Shorthand: "1d", "2h30m", "5m30s\""""
# Type alias for run commands
Run = t.Callable[..., Result]
def get_default_visibility() -> str:
return os.getenv("TOOT_POST_VISIBILITY", "public")
def get_default_map():
settings = get_settings()
common = settings.get("common", {})
commands = settings.get("commands", {})
return {**common, **commands}
# Tweak the Click context
# https://click.palletsprojects.com/en/8.1.x/api/#context
CONTEXT = dict(
# Enable using environment variables to set options
auto_envvar_prefix="TOOT",
# Add shorthand -h for invoking help
help_option_names=["-h", "--help"],
# Always show default values for options
show_default=True,
# Load command defaults from settings
default_map=get_default_map(),
)
class Context(t.NamedTuple):
app: t.Optional[App]
user: t.Optional[User] = None
color: bool = False
debug: bool = False
quiet: bool = False
class TootObj(t.NamedTuple):
"""Data to add to Click context"""
color: bool = True
debug: bool = False
quiet: bool = False
# Pass a context for testing purposes
test_ctx: t.Optional[Context] = None
def pass_context(f: "t.Callable[te.Concatenate[Context, P], R]") -> "t.Callable[P, R]":
"""Pass the toot Context as first argument."""
@wraps(f)
def wrapped(*args: "P.args", **kwargs: "P.kwargs") -> R:
return f(_get_context(), *args, **kwargs)
return wrapped
def _get_context() -> Context:
click_context = click.get_current_context()
obj: TootObj = click_context.obj
# This is used to pass a context for testing, not used in normal usage
if obj.test_ctx:
return obj.test_ctx
user, app = config.get_active_user_app()
if not user or not app:
raise click.ClickException("This command requires you to be logged in.")
return Context(app, user, obj.color, obj.debug, obj.quiet)
json_option = click.option(
"--json",
is_flag=True,
default=False,
help="Print data as JSON rather than human readable text"
)
@click.group(context_settings=CONTEXT)
@click.option("-w", "--max-width", type=int, default=80, help="Maximum width for content rendered by toot")
@click.option("--debug/--no-debug", default=False, help="Log debug info to stderr")
@click.option("--color/--no-color", default=sys.stdout.isatty(), help="Use ANSI color in output")
@click.option("--quiet/--no-quiet", default=False, help="Don't print anything to stdout")
@click.version_option(__version__, message="%(prog)s v%(version)s")
@click.pass_context
def cli(ctx: click.Context, max_width: int, color: bool, debug: bool, quiet: bool):
"""Toot is a Mastodon CLI"""
ctx.obj = TootObj(color, debug, quiet)
ctx.color = color
ctx.max_content_width = max_width
if debug:
logging.basicConfig(level=logging.DEBUG)
from toot.cli import accounts # noqa
from toot.cli import auth # noqa
from toot.cli import lists # noqa
from toot.cli import post # noqa
from toot.cli import read # noqa
from toot.cli import statuses # noqa
from toot.cli import tags # noqa
from toot.cli import timelines # noqa
from toot.cli import tui # noqa

257
toot/cli/accounts.py Normal file
View File

@ -0,0 +1,257 @@
import click
import json as pyjson
from typing import BinaryIO, Optional
from toot import api
from toot.cli import PRIVACY_CHOICES, cli, json_option, Context, pass_context
from toot.cli.validators import validate_language
from toot.output import print_acct_list
@cli.command(name="update_account")
@click.option("--display-name", help="The display name to use for the profile.")
@click.option("--note", help="The account bio.")
@click.option(
"--avatar",
type=click.File(mode="rb"),
help="Path to the avatar image to set.",
)
@click.option(
"--header",
type=click.File(mode="rb"),
help="Path to the header image to set.",
)
@click.option(
"--bot/--no-bot",
default=None,
help="Whether the account has a bot flag.",
)
@click.option(
"--discoverable/--no-discoverable",
default=None,
help="Whether the account should be shown in the profile directory.",
)
@click.option(
"--locked/--no-locked",
default=None,
help="Whether manual approval of follow requests is required.",
)
@click.option(
"--privacy",
type=click.Choice(PRIVACY_CHOICES),
help="Default post privacy for authored statuses.",
)
@click.option(
"--sensitive/--no-sensitive",
default=None,
help="Whether to mark authored statuses as sensitive by default.",
)
@click.option(
"--language",
callback=validate_language,
help="Default language to use for authored statuses (ISO 639-1).",
)
@json_option
@pass_context
def update_account(
ctx: Context,
display_name: Optional[str],
note: Optional[str],
avatar: Optional[BinaryIO],
header: Optional[BinaryIO],
bot: Optional[bool],
discoverable: Optional[bool],
locked: Optional[bool],
privacy: Optional[bool],
sensitive: Optional[bool],
language: Optional[bool],
json: bool,
):
"""Update your account details"""
options = [
avatar,
bot,
discoverable,
display_name,
header,
language,
locked,
note,
privacy,
sensitive,
]
if all(option is None for option in options):
raise click.ClickException("Please specify at least one option to update the account")
response = api.update_account(
ctx.app,
ctx.user,
avatar=avatar,
bot=bot,
discoverable=discoverable,
display_name=display_name,
header=header,
language=language,
locked=locked,
note=note,
privacy=privacy,
sensitive=sensitive,
)
if json:
click.echo(response.text)
else:
click.secho("✓ Account updated", fg="green")
@cli.command()
@click.argument("account")
@json_option
@pass_context
def follow(ctx: Context, account: str, json: bool):
"""Follow an account"""
found_account = api.find_account(ctx.app, ctx.user, account)
response = api.follow(ctx.app, ctx.user, found_account["id"])
if json:
click.echo(response.text)
else:
click.secho(f"✓ You are now following {account}", fg="green")
@cli.command()
@click.argument("account")
@json_option
@pass_context
def unfollow(ctx: Context, account: str, json: bool):
"""Unfollow an account"""
found_account = api.find_account(ctx.app, ctx.user, account)
response = api.unfollow(ctx.app, ctx.user, found_account["id"])
if json:
click.echo(response.text)
else:
click.secho(f"✓ You are no longer following {account}", fg="green")
@cli.command()
@click.argument("account", required=False)
@json_option
@pass_context
def following(ctx: Context, account: Optional[str], json: bool):
"""List accounts followed by an account.
If no account is given list accounts followed by you.
"""
account = account or ctx.user.username
found_account = api.find_account(ctx.app, ctx.user, account)
accounts = api.following(ctx.app, ctx.user, found_account["id"])
if json:
click.echo(pyjson.dumps(accounts))
else:
print_acct_list(accounts)
@cli.command()
@click.argument("account", required=False)
@json_option
@pass_context
def followers(ctx: Context, account: Optional[str], json: bool):
"""List accounts following an account.
If no account given list accounts following you."""
account = account or ctx.user.username
found_account = api.find_account(ctx.app, ctx.user, account)
accounts = api.followers(ctx.app, ctx.user, found_account["id"])
if json:
click.echo(pyjson.dumps(accounts))
else:
print_acct_list(accounts)
@cli.command()
@click.argument("account")
@json_option
@pass_context
def mute(ctx: Context, account: str, json: bool):
"""Mute an account"""
found_account = api.find_account(ctx.app, ctx.user, account)
response = api.mute(ctx.app, ctx.user, found_account["id"])
if json:
click.echo(response.text)
else:
click.secho(f"✓ You have muted {account}", fg="green")
@cli.command()
@click.argument("account")
@json_option
@pass_context
def unmute(ctx: Context, account: str, json: bool):
"""Unmute an account"""
found_account = api.find_account(ctx.app, ctx.user, account)
response = api.unmute(ctx.app, ctx.user, found_account["id"])
if json:
click.echo(response.text)
else:
click.secho(f"{account} is no longer muted", fg="green")
@cli.command()
@json_option
@pass_context
def muted(ctx: Context, json: bool):
"""List muted accounts"""
response = api.muted(ctx.app, ctx.user)
if json:
click.echo(pyjson.dumps(response))
else:
if len(response) > 0:
click.echo("Muted accounts:")
print_acct_list(response)
else:
click.echo("No accounts muted")
@cli.command()
@click.argument("account")
@json_option
@pass_context
def block(ctx: Context, account: str, json: bool):
"""Block an account"""
found_account = api.find_account(ctx.app, ctx.user, account)
response = api.block(ctx.app, ctx.user, found_account["id"])
if json:
click.echo(response.text)
else:
click.secho(f"✓ You are now blocking {account}", fg="green")
@cli.command()
@click.argument("account")
@json_option
@pass_context
def unblock(ctx: Context, account: str, json: bool):
"""Unblock an account"""
found_account = api.find_account(ctx.app, ctx.user, account)
response = api.unblock(ctx.app, ctx.user, found_account["id"])
if json:
click.echo(response.text)
else:
click.secho(f"{account} is no longer blocked", fg="green")
@cli.command()
@json_option
@pass_context
def blocked(ctx: Context, json: bool):
"""List blocked accounts"""
response = api.blocked(ctx.app, ctx.user)
if json:
click.echo(pyjson.dumps(response))
else:
if len(response) > 0:
click.echo("Blocked accounts:")
print_acct_list(response)
else:
click.echo("No accounts blocked")

158
toot/cli/auth.py Normal file
View File

@ -0,0 +1,158 @@
import click
import platform
import sys
import webbrowser
from click.shell_completion import CompletionItem
from click.types import StringParamType
from toot import api, config, __version__
from toot.auth import get_or_create_app, login_auth_code, login_username_password
from toot.cli import cli
from toot.cli.validators import validate_instance
instance_option = click.option(
"--instance", "-i", "base_url",
prompt="Enter instance URL",
default="https://mastodon.social",
callback=validate_instance,
help="""Domain or base URL of the instance to log into,
e.g. 'mastodon.social' or 'https://mastodon.social'""",
)
class AccountParamType(StringParamType):
"""Custom type to add shell completion for account names"""
def shell_complete(self, ctx, param, incomplete: str):
accounts = config.load_config()["users"].keys()
return [
CompletionItem(a)
for a in accounts
if a.lower().startswith(incomplete.lower())
]
@cli.command()
def auth():
"""Show logged in accounts and instances"""
config_data = config.load_config()
if not config_data["users"]:
click.echo("You are not logged in to any accounts")
return
active_user = config_data["active_user"]
click.echo("Authenticated accounts:")
for uid, u in config_data["users"].items():
active_label = "ACTIVE" if active_user == uid else ""
uid = click.style(uid, fg="green")
active_label = click.style(active_label, fg="yellow")
click.echo(f"* {uid} {active_label}")
path = config.get_config_file_path()
path = click.style(path, "blue")
click.echo(f"\nAuth tokens are stored in: {path}")
@cli.command()
def env():
"""Print environment information for inclusion in bug reports."""
click.echo(f"toot {__version__}")
click.echo(f"Python {sys.version}")
click.echo(platform.platform())
@cli.command(name="login_cli")
@instance_option
@click.option("--email", "-e", help="Email address to log in with", prompt=True)
@click.option("--password", "-p", hidden=True, prompt=True, hide_input=True)
def login_cli(base_url: str, email: str, password: str):
"""
Log into an instance from the console (not recommended)
Does NOT support two factor authentication, may not work on instances
other than Mastodon, mostly useful for scripting.
"""
app = get_or_create_app(base_url)
login_username_password(app, email, password)
click.secho("✓ Successfully logged in.", fg="green")
click.echo("Access token saved to config at: ", nl=False)
click.secho(config.get_config_file_path(), fg="green")
LOGIN_EXPLANATION = """This authentication method requires you to log into your
Mastodon instance in your browser, where you will be asked to authorize toot to
access your account. When you do, you will be given an authorization code which
you need to paste here.""".replace("\n", " ")
@cli.command()
@instance_option
def login(base_url: str):
"""Log into an instance using your browser (recommended)"""
app = get_or_create_app(base_url)
url = api.get_browser_login_url(app)
click.echo(click.wrap_text(LOGIN_EXPLANATION))
click.echo("\nLogin URL:")
click.echo(url)
yesno = click.prompt("Open link in default browser? [Y/n]", default="Y", show_default=False)
if not yesno or yesno.lower() == 'y':
webbrowser.open(url)
authorization_code = ""
while not authorization_code:
authorization_code = click.prompt("Authorization code")
login_auth_code(app, authorization_code)
click.echo()
click.secho("✓ Successfully logged in.", fg="green")
@cli.command()
@click.argument("account", type=AccountParamType(), required=False)
def logout(account: str):
"""Log out of ACCOUNT, delete stored access keys"""
accounts = _get_accounts_list()
if not account:
raise click.ClickException(f"Specify account to log out:\n{accounts}")
user = config.load_user(account)
if not user:
raise click.ClickException(f"Account not found. Logged in accounts:\n{accounts}")
config.delete_user(user)
click.secho(f"✓ Account {account} logged out", fg="green")
@cli.command()
@click.argument("account", type=AccountParamType(), required=False)
def activate(account: str):
"""Switch to logged in ACCOUNT."""
accounts = _get_accounts_list()
if not account:
raise click.ClickException(f"Specify account to activate:\n{accounts}")
user = config.load_user(account)
if not user:
raise click.ClickException(f"Account not found. Logged in accounts:\n{accounts}")
config.activate_user(user)
click.secho(f"✓ Account {account} activated", fg="green")
def _get_accounts_list() -> str:
accounts = config.load_config()["users"].keys()
if not accounts:
raise click.ClickException("You're not logged into any accounts")
return "\n".join([f"* {acct}" for acct in accounts])

218
toot/cli/lists.py Normal file
View File

@ -0,0 +1,218 @@
import click
from toot import api, config
from toot.cli import Context, cli, pass_context
from toot.output import print_list_accounts, print_lists, print_warning
@cli.group(invoke_without_command=True)
@click.pass_context
def lists(ctx: click.Context):
"""Display and manage lists"""
if ctx.invoked_subcommand is None:
print_warning("`toot lists` is deprecated in favour of `toot lists list`")
user, app = config.get_active_user_app()
if not user or not app:
raise click.ClickException("This command requires you to be logged in.")
lists = api.get_lists(app, user)
if lists:
print_lists(lists)
else:
click.echo("You have no lists defined.")
@lists.command()
@pass_context
def list(ctx: Context):
"""List all your lists"""
lists = api.get_lists(ctx.app, ctx.user)
if lists:
print_lists(lists)
else:
click.echo("You have no lists defined.")
@lists.command()
@click.argument("title", required=False)
@click.option("--id", help="List ID if not title is given")
@pass_context
def accounts(ctx: Context, title: str, id: str):
"""List the accounts in a list"""
list_id = _get_list_id(ctx, title, id)
response = api.get_list_accounts(ctx.app, ctx.user, list_id)
print_list_accounts(response)
@lists.command()
@click.argument("title")
@click.option(
"--replies-policy",
type=click.Choice(["followed", "list", "none"]),
default="none",
help="Replies policy"
)
@pass_context
def create(ctx: Context, title: str, replies_policy: str):
"""Create a list"""
api.create_list(ctx.app, ctx.user, title=title, replies_policy=replies_policy)
click.secho(f"✓ List \"{title}\" created.", fg="green")
@lists.command()
@click.argument("title", required=False)
@click.option("--id", help="List ID if not title is given")
@pass_context
def delete(ctx: Context, title: str, id: str):
"""Delete a list"""
list_id = _get_list_id(ctx, title, id)
api.delete_list(ctx.app, ctx.user, list_id)
click.secho(f"✓ List \"{title if title else id}\" deleted.", fg="green")
@lists.command()
@click.argument("title", required=False)
@click.argument("account")
@click.option("--id", help="List ID if not title is given")
@pass_context
def add(ctx: Context, title: str, account: str, id: str):
"""Add an account to a list"""
list_id = _get_list_id(ctx, title, id)
found_account = api.find_account(ctx.app, ctx.user, account)
try:
api.add_accounts_to_list(ctx.app, ctx.user, list_id, [found_account["id"]])
except Exception:
# TODO: this is slow, improve
# if we failed to add the account, try to give a
# more specific error message than "record not found"
my_accounts = api.followers(ctx.app, ctx.user, found_account["id"])
found = False
if my_accounts:
for my_account in my_accounts:
if my_account["id"] == found_account["id"]:
found = True
break
if found is False:
raise click.ClickException(f"You must follow @{account} before adding this account to a list.")
raise
click.secho(f"✓ Added account \"{account}\"", fg="green")
@lists.command()
@click.argument("title", required=False)
@click.argument("account")
@click.option("--id", help="List ID if not title is given")
@pass_context
def remove(ctx: Context, title: str, account: str, id: str):
"""Remove an account from a list"""
list_id = _get_list_id(ctx, title, id)
found_account = api.find_account(ctx.app, ctx.user, account)
api.remove_accounts_from_list(ctx.app, ctx.user, list_id, [found_account["id"]])
click.secho(f"✓ Removed account \"{account}\"", fg="green")
@cli.command(name="list_accounts", hidden=True)
@click.argument("title", required=False)
@click.option("--id", help="List ID if not title is given")
@pass_context
def list_accounts(ctx: Context, title: str, id: str):
"""List the accounts in a list"""
print_warning("`toot list_accounts` is deprecated in favour of `toot lists accounts`")
list_id = _get_list_id(ctx, title, id)
response = api.get_list_accounts(ctx.app, ctx.user, list_id)
print_list_accounts(response)
@cli.command(name="list_create", hidden=True)
@click.argument("title")
@click.option(
"--replies-policy",
type=click.Choice(["followed", "list", "none"]),
default="none",
help="Replies policy"
)
@pass_context
def list_create(ctx: Context, title: str, replies_policy: str):
"""Create a list"""
print_warning("`toot list_create` is deprecated in favour of `toot lists create`")
api.create_list(ctx.app, ctx.user, title=title, replies_policy=replies_policy)
click.secho(f"✓ List \"{title}\" created.", fg="green")
@cli.command(name="list_delete", hidden=True)
@click.argument("title", required=False)
@click.option("--id", help="List ID if not title is given")
@pass_context
def list_delete(ctx: Context, title: str, id: str):
"""Delete a list"""
print_warning("`toot list_delete` is deprecated in favour of `toot lists delete`")
list_id = _get_list_id(ctx, title, id)
api.delete_list(ctx.app, ctx.user, list_id)
click.secho(f"✓ List \"{title if title else id}\" deleted.", fg="green")
@cli.command(name="list_add", hidden=True)
@click.argument("title", required=False)
@click.argument("account")
@click.option("--id", help="List ID if not title is given")
@pass_context
def list_add(ctx: Context, title: str, account: str, id: str):
"""Add an account to a list"""
print_warning("`toot list_add` is deprecated in favour of `toot lists add`")
list_id = _get_list_id(ctx, title, id)
found_account = api.find_account(ctx.app, ctx.user, account)
try:
api.add_accounts_to_list(ctx.app, ctx.user, list_id, [found_account["id"]])
except Exception:
# if we failed to add the account, try to give a
# more specific error message than "record not found"
my_accounts = api.followers(ctx.app, ctx.user, found_account["id"])
found = False
if my_accounts:
for my_account in my_accounts:
if my_account["id"] == found_account["id"]:
found = True
break
if found is False:
raise click.ClickException(f"You must follow @{account} before adding this account to a list.")
raise
click.secho(f"✓ Added account \"{account}\"", fg="green")
@cli.command(name="list_remove", hidden=True)
@click.argument("title", required=False)
@click.argument("account")
@click.option("--id", help="List ID if not title is given")
@pass_context
def list_remove(ctx: Context, title: str, account: str, id: str):
"""Remove an account from a list"""
print_warning("`toot list_remove` is deprecated in favour of `toot lists remove`")
list_id = _get_list_id(ctx, title, id)
found_account = api.find_account(ctx.app, ctx.user, account)
api.remove_accounts_from_list(ctx.app, ctx.user, list_id, [found_account["id"]])
click.secho(f"✓ Removed account \"{account}\"", fg="green")
def _get_list_id(ctx: Context, title, list_id):
if not list_id and not title:
raise click.ClickException("Please specify list title or ID")
lists = api.get_lists(ctx.app, ctx.user)
matched_ids = [
list["id"] for list in lists
if list["title"].lower() == title.lower() or list["id"] == list_id
]
if not matched_ids:
raise click.ClickException("List not found")
if len(matched_ids) > 1:
raise click.ClickException("Found multiple lists with the same title, please specify the ID instead")
return matched_ids[0]

281
toot/cli/post.py Normal file
View File

@ -0,0 +1,281 @@
import click
import os
import sys
from datetime import datetime, timedelta, timezone
from time import sleep, time
from typing import BinaryIO, Optional, Tuple
from toot import api
from toot.cli import cli, json_option, pass_context, Context
from toot.cli import DURATION_EXAMPLES, VISIBILITY_CHOICES
from toot.cli.validators import validate_duration, validate_language
from toot.entities import MediaAttachment, from_dict
from toot.utils import EOF_KEY, delete_tmp_status_file, editor_input, multiline_input
from toot.utils.datetime import parse_datetime
@cli.command()
@click.argument("text", required=False)
@click.option(
"--media", "-m",
help="""Path to media file to attach, can be used multiple times to attach
multiple files.""",
type=click.File(mode="rb"),
multiple=True
)
@click.option(
"--description", "-d", "descriptions",
help="""Plain-text description of the media for accessibility purposes, one
per attached media""",
multiple=True,
)
@click.option(
"--thumbnail", "thumbnails",
help="Path to an image file to serve as media thumbnail, one per attached media",
type=click.File(mode="rb"),
multiple=True
)
@click.option(
"--visibility", "-v",
help="Post visibility",
type=click.Choice(VISIBILITY_CHOICES),
default="public",
)
@click.option(
"--sensitive", "-s",
help="Mark status and attached media as sensitive",
default=False,
is_flag=True,
)
@click.option(
"--spoiler-text", "-p",
help="Text to be shown as a warning or subject before the actual content.",
)
@click.option(
"--reply-to", "-r",
help="ID of the status being replied to, if status is a reply.",
)
@click.option(
"--language", "-l",
help="ISO 639-1 language code of the toot, to skip automatic detection.",
callback=validate_language,
)
@click.option(
"--editor", "-e",
is_flag=False,
flag_value=os.getenv("EDITOR"),
help="""Specify an editor to compose your toot. When used without a value
it will use the editor defined in the $EDITOR environment variable.""",
)
@click.option(
"--scheduled-at",
help="""ISO 8601 Datetime at which to schedule a status. Must be at least 5
minutes in the future.""",
)
@click.option(
"--scheduled-in",
help=f"""Schedule the toot to be posted after a given amount of time,
{DURATION_EXAMPLES}. Must be at least 5 minutes.""",
callback=validate_duration,
)
@click.option(
"--content-type", "-t",
help="MIME type for the status text (not supported on all instances)",
)
@click.option(
"--poll-option",
help="Possible answer to the poll, can be given multiple times.",
multiple=True,
)
@click.option(
"--poll-expires-in",
help=f"Duration that the poll should be open, {DURATION_EXAMPLES}",
callback=validate_duration,
default="24h",
)
@click.option(
"--poll-multiple",
help="Allow multiple answers to be selected.",
is_flag=True,
default=False,
)
@click.option(
"--poll-hide-totals",
help="Hide vote counts until the poll ends.",
is_flag=True,
default=False,
)
@json_option
@pass_context
def post(
ctx: Context,
text: Optional[str],
media: Tuple[str],
descriptions: Tuple[str],
thumbnails: Tuple[str],
visibility: str,
sensitive: bool,
spoiler_text: Optional[str],
reply_to: Optional[str],
language: Optional[str],
editor: Optional[str],
scheduled_at: Optional[str],
scheduled_in: Optional[int],
content_type: Optional[str],
poll_option: Tuple[str],
poll_expires_in: int,
poll_multiple: bool,
poll_hide_totals: bool,
json: bool
):
"""Post a new status"""
if len(media) > 4:
raise click.ClickException("Cannot attach more than 4 files.")
media_ids = _upload_media(ctx.app, ctx.user, media, descriptions, thumbnails)
status_text = _get_status_text(text, editor, media)
scheduled_at = _get_scheduled_at(scheduled_at, scheduled_in)
if not status_text and not media_ids:
raise click.ClickException("You must specify either text or media to post.")
response = api.post_status(
ctx.app,
ctx.user,
status_text,
visibility=visibility,
media_ids=media_ids,
sensitive=sensitive,
spoiler_text=spoiler_text,
in_reply_to_id=reply_to,
language=language,
scheduled_at=scheduled_at,
content_type=content_type,
poll_options=poll_option,
poll_expires_in=poll_expires_in,
poll_multiple=poll_multiple,
poll_hide_totals=poll_hide_totals,
)
if json:
click.echo(response.text)
else:
status = response.json()
if "scheduled_at" in status:
scheduled_at = parse_datetime(status["scheduled_at"])
scheduled_at = datetime.strftime(scheduled_at, "%Y-%m-%d %H:%M:%S%z")
click.echo(f"Toot scheduled for: {scheduled_at}")
else:
click.echo(f"Toot posted: {status['url']}")
delete_tmp_status_file()
@cli.command()
@click.argument("file", type=click.File(mode="rb"))
@click.option(
"--description", "-d",
help="Plain-text description of the media for accessibility purposes"
)
@json_option
@pass_context
def upload(
ctx: Context,
file: BinaryIO,
description: Optional[str],
json: bool,
):
"""Upload an image or video file
This is probably not very useful, see `toot post --media` instead.
"""
response = _do_upload(ctx.app, ctx.user, file, description, None)
if json:
click.echo(response.text)
else:
media = from_dict(MediaAttachment, response.json())
click.echo()
click.echo(f"Successfully uploaded media ID {media.id}, type '{media.type}'")
click.echo(f"URL: {media.url}")
click.echo(f"Preview URL: {media.preview_url}")
def _get_status_text(text, editor, media):
isatty = sys.stdin.isatty()
if not text and not isatty:
text = sys.stdin.read().rstrip()
if isatty:
if editor:
text = editor_input(editor, text)
elif not text and not media:
click.echo(f"Write or paste your toot. Press {EOF_KEY} to post it.")
text = multiline_input()
return text
def _get_scheduled_at(scheduled_at, scheduled_in):
if scheduled_at:
return scheduled_at
if scheduled_in:
scheduled_at = datetime.now(timezone.utc) + timedelta(seconds=scheduled_in)
return scheduled_at.replace(microsecond=0).isoformat()
return None
def _upload_media(app, user, media, descriptions, thumbnails):
# Match media to corresponding descriptions and thumbnail
media = media or []
descriptions = descriptions or []
thumbnails = thumbnails or []
uploaded_media = []
for idx, file in enumerate(media):
description = descriptions[idx].strip() if idx < len(descriptions) else None
thumbnail = thumbnails[idx] if idx < len(thumbnails) else None
result = _do_upload(app, user, file, description, thumbnail).json()
uploaded_media.append(result)
_wait_until_all_processed(app, user, uploaded_media)
return [m["id"] for m in uploaded_media]
def _do_upload(app, user, file, description, thumbnail):
return api.upload_media(app, user, file, description=description, thumbnail=thumbnail)
def _wait_until_all_processed(app, user, uploaded_media):
"""
Media is uploaded asynchronously, and cannot be attached until the server
has finished processing it. This function waits for that to happen.
Once media is processed, it will have the URL populated.
"""
if all(m["url"] for m in uploaded_media):
return
# Timeout after waiting 1 minute
start_time = time()
timeout = 60
click.echo("Waiting for media to finish processing...")
for media in uploaded_media:
_wait_until_processed(app, user, media, start_time, timeout)
def _wait_until_processed(app, user, media, start_time, timeout):
if media["url"]:
return
media = api.get_media(app, user, media["id"])
while not media["url"]:
sleep(1)
if time() > start_time + timeout:
raise click.ClickException(f"Media not processed by server after {timeout} seconds. Aborting.")
media = api.get_media(app, user, media["id"])

114
toot/cli/read.py Normal file
View File

@ -0,0 +1,114 @@
import click
import json as pyjson
from itertools import chain
from typing import Optional
from toot import api
from toot.cli.validators import validate_instance
from toot.entities import Instance, Status, from_dict, Account
from toot.exceptions import ApiError, ConsoleError
from toot.output import print_account, print_instance, print_search_results, print_status, print_timeline
from toot.cli import cli, json_option, pass_context, Context
@cli.command()
@json_option
@pass_context
def whoami(ctx: Context, json: bool):
"""Display logged in user details"""
response = api.verify_credentials(ctx.app, ctx.user)
if json:
click.echo(response.text)
else:
account = from_dict(Account, response.json())
print_account(account)
@cli.command()
@click.argument("account")
@json_option
@pass_context
def whois(ctx: Context, account: str, json: bool):
"""Display account details"""
account_dict = api.find_account(ctx.app, ctx.user, account)
# Here it's not possible to avoid parsing json since it's needed to find the account.
if json:
click.echo(pyjson.dumps(account_dict))
else:
account_obj = from_dict(Account, account_dict)
print_account(account_obj)
@cli.command()
@click.argument("instance_url", required=False, callback=validate_instance)
@json_option
@pass_context
def instance(ctx: Context, instance_url: Optional[str], json: bool):
"""Display instance details"""
default_url = ctx.app.base_url if ctx.app else None
base_url = instance_url or default_url
if not base_url:
raise ConsoleError("Please specify an instance.")
try:
response = api.get_instance(base_url)
except ApiError:
raise ConsoleError(
f"Instance not found at {base_url}.\n" +
"The given domain probably does not host a Mastodon instance."
)
if json:
click.echo(response.text)
else:
instance = from_dict(Instance, response.json())
print_instance(instance)
@cli.command()
@click.argument("query")
@click.option("-r", "--resolve", is_flag=True, help="Resolve non-local accounts")
@json_option
@pass_context
def search(ctx: Context, query: str, resolve: bool, json: bool):
"""Search for users or hashtags"""
response = api.search(ctx.app, ctx.user, query, resolve)
if json:
click.echo(response.text)
else:
print_search_results(response.json())
@cli.command()
@click.argument("status_id")
@json_option
@pass_context
def status(ctx: Context, status_id: str, json: bool):
"""Show a single status"""
response = api.fetch_status(ctx.app, ctx.user, status_id)
if json:
click.echo(response.text)
else:
status = from_dict(Status, response.json())
print_status(status)
@cli.command()
@click.argument("status_id")
@json_option
@pass_context
def thread(ctx: Context, status_id: str, json: bool):
"""Show thread for a toot."""
context_response = api.context(ctx.app, ctx.user, status_id)
if json:
click.echo(context_response.text)
else:
toot = api.fetch_status(ctx.app, ctx.user, status_id).json()
context = context_response.json()
statuses = chain(context["ancestors"], [toot], context["descendants"])
print_timeline(from_dict(Status, s) for s in statuses)

148
toot/cli/statuses.py Normal file
View File

@ -0,0 +1,148 @@
import click
from toot import api
from toot.cli import cli, json_option, Context, pass_context
from toot.cli import VISIBILITY_CHOICES
from toot.output import print_table
@cli.command()
@click.argument("status_id")
@json_option
@pass_context
def delete(ctx: Context, status_id: str, json: bool):
"""Delete a status"""
response = api.delete_status(ctx.app, ctx.user, status_id)
if json:
click.echo(response.text)
else:
click.secho("✓ Status deleted", fg="green")
@cli.command()
@click.argument("status_id")
@json_option
@pass_context
def favourite(ctx: Context, status_id: str, json: bool):
"""Favourite a status"""
response = api.favourite(ctx.app, ctx.user, status_id)
if json:
click.echo(response.text)
else:
click.secho("✓ Status favourited", fg="green")
@cli.command()
@click.argument("status_id")
@json_option
@pass_context
def unfavourite(ctx: Context, status_id: str, json: bool):
"""Unfavourite a status"""
response = api.unfavourite(ctx.app, ctx.user, status_id)
if json:
click.echo(response.text)
else:
click.secho("✓ Status unfavourited", fg="green")
@cli.command()
@click.argument("status_id")
@click.option(
"--visibility", "-v",
help="Post visibility",
type=click.Choice(VISIBILITY_CHOICES),
default="public",
)
@json_option
@pass_context
def reblog(ctx: Context, status_id: str, visibility: str, json: bool):
"""Reblog (boost) a status"""
response = api.reblog(ctx.app, ctx.user, status_id, visibility=visibility)
if json:
click.echo(response.text)
else:
click.secho("✓ Status reblogged", fg="green")
@cli.command()
@click.argument("status_id")
@json_option
@pass_context
def unreblog(ctx: Context, status_id: str, json: bool):
"""Unreblog (unboost) a status"""
response = api.unreblog(ctx.app, ctx.user, status_id)
if json:
click.echo(response.text)
else:
click.secho("✓ Status unreblogged", fg="green")
@cli.command()
@click.argument("status_id")
@json_option
@pass_context
def pin(ctx: Context, status_id: str, json: bool):
"""Pin a status"""
response = api.pin(ctx.app, ctx.user, status_id)
if json:
click.echo(response.text)
else:
click.secho("✓ Status pinned", fg="green")
@cli.command()
@click.argument("status_id")
@json_option
@pass_context
def unpin(ctx: Context, status_id: str, json: bool):
"""Unpin a status"""
response = api.unpin(ctx.app, ctx.user, status_id)
if json:
click.echo(response.text)
else:
click.secho("✓ Status unpinned", fg="green")
@cli.command()
@click.argument("status_id")
@json_option
@pass_context
def bookmark(ctx: Context, status_id: str, json: bool):
"""Bookmark a status"""
response = api.bookmark(ctx.app, ctx.user, status_id)
if json:
click.echo(response.text)
else:
click.secho("✓ Status bookmarked", fg="green")
@cli.command()
@click.argument("status_id")
@json_option
@pass_context
def unbookmark(ctx: Context, status_id: str, json: bool):
"""Unbookmark a status"""
response = api.unbookmark(ctx.app, ctx.user, status_id)
if json:
click.echo(response.text)
else:
click.secho("✓ Status unbookmarked", fg="green")
@cli.command(name="reblogged_by")
@click.argument("status_id")
@json_option
@pass_context
def reblogged_by(ctx: Context, status_id: str, json: bool):
"""Show accounts that reblogged a status"""
response = api.reblogged_by(ctx.app, ctx.user, status_id)
if json:
click.echo(response.text)
else:
rows = [[a["acct"], a["display_name"]] for a in response.json()]
if rows:
headers = ["Account", "Display name"]
print_table(headers, rows)
else:
click.echo("This status is not reblogged by anyone")

163
toot/cli/tags.py Normal file
View File

@ -0,0 +1,163 @@
import click
import json as pyjson
from toot import api
from toot.cli import cli, pass_context, json_option, Context
from toot.entities import Tag, from_dict
from toot.output import print_tag_list, print_warning
@cli.group()
def tags():
"""List, follow, and unfollow tags"""
@tags.command()
@click.argument("tag")
@json_option
@pass_context
def info(ctx: Context, tag, json: bool):
"""Show a hashtag and its associated information"""
tag = api.find_tag(ctx.app, ctx.user, tag)
if not tag:
raise click.ClickException("Tag not found")
if json:
click.echo(pyjson.dumps(tag))
else:
tag = from_dict(Tag, tag)
click.secho(f"#{tag.name}", fg="yellow")
click.secho(tag.url, italic=True)
if tag.following:
click.echo("Followed")
else:
click.echo("Not followed")
@tags.command()
@json_option
@pass_context
def followed(ctx: Context, json: bool):
"""List followed tags"""
tags = api.followed_tags(ctx.app, ctx.user)
if json:
click.echo(pyjson.dumps(tags))
else:
if tags:
print_tag_list(tags)
else:
click.echo("You're not following any hashtags")
@tags.command()
@click.argument("tag")
@json_option
@pass_context
def follow(ctx: Context, tag: str, json: bool):
"""Follow a hashtag"""
tag = tag.lstrip("#")
response = api.follow_tag(ctx.app, ctx.user, tag)
if json:
click.echo(response.text)
else:
click.secho(f"✓ You are now following #{tag}", fg="green")
@tags.command()
@click.argument("tag")
@json_option
@pass_context
def unfollow(ctx: Context, tag: str, json: bool):
"""Unfollow a hashtag"""
tag = tag.lstrip("#")
response = api.unfollow_tag(ctx.app, ctx.user, tag)
if json:
click.echo(response.text)
else:
click.secho(f"✓ You are no longer following #{tag}", fg="green")
@tags.command()
@json_option
@pass_context
def featured(ctx: Context, json: bool):
"""List hashtags featured on your profile."""
response = api.featured_tags(ctx.app, ctx.user)
if json:
click.echo(response.text)
else:
tags = response.json()
if tags:
print_tag_list(tags)
else:
click.echo("You don't have any featured hashtags")
@tags.command()
@click.argument("tag")
@json_option
@pass_context
def feature(ctx: Context, tag: str, json: bool):
"""Feature a hashtag on your profile"""
tag = tag.lstrip("#")
response = api.feature_tag(ctx.app, ctx.user, tag)
if json:
click.echo(response.text)
else:
click.secho(f"✓ Tag #{tag} is now featured", fg="green")
@tags.command()
@click.argument("tag")
@json_option
@pass_context
def unfeature(ctx: Context, tag: str, json: bool):
"""Unfollow a hashtag
TAG can either be a tag name like "#foo" or "foo" or a tag ID.
"""
featured_tag = api.find_featured_tag(ctx.app, ctx.user, tag)
# TODO: should this be idempotent?
if not featured_tag:
raise click.ClickException(f"Tag {tag} is not featured")
response = api.unfeature_tag(ctx.app, ctx.user, featured_tag["id"])
if json:
click.echo(response.text)
else:
click.secho(f"✓ Tag #{featured_tag['name']} is no longer featured", fg="green")
# -- Deprecated commands -------------------------------------------------------
@cli.command(name="tags_followed", hidden=True)
@pass_context
def tags_followed(ctx: Context):
"""List hashtags you follow"""
print_warning("`toot tags_followed` is deprecated in favour of `toot tags followed`")
response = api.followed_tags(ctx.app, ctx.user)
print_tag_list(response)
@cli.command(name="tags_follow", hidden=True)
@click.argument("tag")
@pass_context
def tags_follow(ctx: Context, tag: str):
"""Follow a hashtag"""
print_warning("`toot tags_follow` is deprecated in favour of `toot tags follow`")
tag = tag.lstrip("#")
api.follow_tag(ctx.app, ctx.user, tag)
click.secho(f"✓ You are now following #{tag}", fg="green")
@cli.command(name="tags_unfollow", hidden=True)
@click.argument("tag")
@pass_context
def tags_unfollow(ctx: Context, tag: str):
"""Unfollow a hashtag"""
print_warning("`toot tags_unfollow` is deprecated in favour of `toot tags unfollow`")
tag = tag.lstrip("#")
api.unfollow_tag(ctx.app, ctx.user, tag)
click.secho(f"✓ You are no longer following #{tag}", fg="green")

180
toot/cli/timelines.py Normal file
View File

@ -0,0 +1,180 @@
import sys
import click
from toot import api
from toot.cli import cli, pass_context, Context
from typing import Optional
from toot.cli.validators import validate_instance
from toot.entities import Notification, Status, from_dict
from toot.output import print_notifications, print_timeline
@cli.command()
@click.option(
"--instance", "-i",
callback=validate_instance,
help="""Domain or base URL of the instance from which to read,
e.g. 'mastodon.social' or 'https://mastodon.social'""",
)
@click.option("--account", "-a", help="Show account timeline")
@click.option("--list", help="Show list timeline")
@click.option("--tag", "-t", help="Show hashtag timeline")
@click.option("--public", "-p", is_flag=True, help="Show public timeline")
@click.option(
"--local", "-l", is_flag=True,
help="Show only statuses from the local instance (public and tag timelines only)"
)
@click.option(
"--reverse", "-r", is_flag=True,
help="Reverse the order of the shown timeline (new posts at the bottom)"
)
@click.option(
"--once", "-1", is_flag=True,
help="Only show the first <count> toots, do not prompt to continue"
)
@click.option(
"--count", "-c", type=int, default=10,
help="Number of posts per page (max 20)"
)
@pass_context
def timeline(
ctx: Context,
instance: Optional[str],
account: Optional[str],
list: Optional[str],
tag: Optional[str],
public: bool,
local: bool,
reverse: bool,
once: bool,
count: int,
):
"""Show recent items in a timeline
By default shows the home timeline.
"""
if len([arg for arg in [tag, list, public, account] if arg]) > 1:
raise click.ClickException("Only one of --public, --tag, --account, or --list can be used at one time.")
if local and not (public or tag):
raise click.ClickException("The --local option is only valid alongside --public or --tag.")
if instance and not (public or tag):
raise click.ClickException("The --instance option is only valid alongside --public or --tag.")
list_id = _get_list_id(ctx, list)
"""Show recent statuses in a timeline"""
generator = api.get_timeline_generator(
ctx.app,
ctx.user,
base_url=instance,
account=account,
list_id=list_id,
tag=tag,
public=public,
local=local,
limit=count,
)
_show_timeline(generator, reverse, once)
@cli.command()
@click.option(
"--reverse", "-r", is_flag=True,
help="Reverse the order of the shown timeline (new posts at the bottom)"
)
@click.option(
"--once", "-1", is_flag=True,
help="Only show the first <count> toots, do not prompt to continue"
)
@click.option(
"--count", "-c", type=int, default=10,
help="Number of posts per page (max 20)"
)
@pass_context
def bookmarks(
ctx: Context,
reverse: bool,
once: bool,
count: int,
):
"""Show recent statuses in a timeline"""
generator = api.bookmark_timeline_generator(ctx.app, ctx.user, limit=count)
_show_timeline(generator, reverse, once)
@cli.command()
@click.option("--clear", help="Dismiss all notifications and exit")
@click.option(
"--reverse", "-r", is_flag=True,
help="Reverse the order of the shown notifications (newest on top)"
)
@click.option(
"--mentions", "-m", is_flag=True,
help="Show only mentions"
)
@pass_context
def notifications(
ctx: Context,
clear: bool,
reverse: bool,
mentions: int,
):
"""Show notifications"""
if clear:
api.clear_notifications(ctx.app, ctx.user)
click.secho("✓ Notifications cleared", fg="green")
return
exclude = []
if mentions:
# Filter everything except mentions
# https://docs.joinmastodon.org/methods/notifications/
exclude = ["follow", "favourite", "reblog", "poll", "follow_request"]
notifications = api.get_notifications(ctx.app, ctx.user, exclude_types=exclude)
if not notifications:
click.echo("You have no notifications")
return
if reverse:
notifications = reversed(notifications)
notifications = [from_dict(Notification, n) for n in notifications]
print_notifications(notifications)
def _show_timeline(generator, reverse, once):
while True:
try:
items = next(generator)
except StopIteration:
click.echo("That's all folks.")
return
if reverse:
items = reversed(items)
statuses = [from_dict(Status, item) for item in items]
print_timeline(statuses)
if once or not sys.stdout.isatty():
break
char = input("\nContinue? [Y/n] ")
if char.lower() == "n":
break
def _get_list_id(ctx: Context, value: Optional[str]) -> Optional[str]:
if not value:
return None
lists = api.get_lists(ctx.app, ctx.user)
for list in lists:
if list["id"] == value or list["title"] == value:
return list["id"]

44
toot/cli/tui.py Normal file
View File

@ -0,0 +1,44 @@
import click
from typing import Optional
from toot.cli import TUI_COLORS, Context, cli, pass_context
from toot.cli.validators import validate_tui_colors
from toot.tui.app import TUI, TuiOptions
COLOR_OPTIONS = ", ".join(TUI_COLORS.keys())
@cli.command()
@click.option(
"-r", "--relative-datetimes",
is_flag=True,
help="Show relative datetimes in status list"
)
@click.option(
"-m", "--media-viewer",
help="Program to invoke with media URLs to display the media files, such as 'feh'"
)
@click.option(
"-c", "--colors",
callback=validate_tui_colors,
help=f"""Number of colors to use, one of {COLOR_OPTIONS}, defaults to 16 if
using --color, and 1 if using --no-color."""
)
@pass_context
def tui(
ctx: Context,
colors: Optional[int],
media_viewer: Optional[str],
relative_datetimes: bool,
):
"""Launches the toot terminal user interface"""
if colors is None:
colors = 16 if ctx.color else 1
options = TuiOptions(
colors=colors,
media_viewer=media_viewer,
relative_datetimes=relative_datetimes,
)
tui = TUI.create(ctx.app, ctx.user, options)
tui.run()

75
toot/cli/validators.py Normal file
View File

@ -0,0 +1,75 @@
import click
import re
from click import Context
from typing import Optional
from toot.cli import TUI_COLORS
def validate_language(ctx: Context, param: str, value: Optional[str]):
if value is None:
return None
value = value.strip().lower()
if re.match(r"^[a-z]{2}$", value):
return value
raise click.BadParameter("Language should be a two letter abbreviation.")
def validate_duration(ctx: Context, param: str, value: Optional[str]) -> Optional[int]:
if value is None:
return None
match = re.match(r"""^
(([0-9]+)\s*(days|day|d))?\s*
(([0-9]+)\s*(hours|hour|h))?\s*
(([0-9]+)\s*(minutes|minute|m))?\s*
(([0-9]+)\s*(seconds|second|s))?\s*
$""", value, re.X)
if not match:
raise click.BadParameter(f"Invalid duration: {value}")
days = match.group(2)
hours = match.group(5)
minutes = match.group(8)
seconds = match.group(11)
days = int(match.group(2) or 0) * 60 * 60 * 24
hours = int(match.group(5) or 0) * 60 * 60
minutes = int(match.group(8) or 0) * 60
seconds = int(match.group(11) or 0)
duration = days + hours + minutes + seconds
if duration == 0:
raise click.BadParameter("Empty duration")
return duration
def validate_instance(ctx: click.Context, param: str, value: Optional[str]):
"""
Instance can be given either as a base URL or the domain name.
Return the base URL.
"""
if not value:
return None
value = value.rstrip("/")
return value if value.startswith("http") else f"https://{value}"
def validate_tui_colors(ctx, param, value) -> Optional[int]:
if value is None:
return None
if value in TUI_COLORS.values():
return value
if value in TUI_COLORS.keys():
return TUI_COLORS[value]
raise click.BadParameter(f"Invalid value: {value}. Expected one of: {', '.join(TUI_COLORS)}")

View File

@ -1,669 +0,0 @@
from itertools import chain
import json
import sys
import platform
from datetime import datetime, timedelta, timezone
from time import sleep, time
from toot import api, config, __version__
from toot.auth import login_interactive, login_browser_interactive, create_app_interactive
from toot.entities import Account, Instance, Notification, Status, from_dict
from toot.exceptions import ApiError, ConsoleError
from toot.output import (print_lists, print_out, print_instance, print_account, print_acct_list,
print_search_results, print_status, print_table, print_timeline, print_notifications,
print_tag_list, print_list_accounts, print_user_list)
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):
if len([arg for arg in [args.tag, args.list, args.public, args.account] if arg]) > 1:
raise ConsoleError("Only one of --public, --tag, --account, or --list can be used at one time.")
if args.local and not (args.public or args.tag):
raise ConsoleError("The --local option is only valid alongside --public or --tag.")
if args.instance and not (args.public or args.tag):
raise ConsoleError("The --instance option is only valid alongside --public or --tag.")
if args.public:
if args.instance:
return api.anon_public_timeline_generator(args.instance, local=args.local, limit=args.count)
else:
return api.public_timeline_generator(app, user, local=args.local, limit=args.count)
elif args.tag:
if args.instance:
return api.anon_tag_timeline_generator(args.instance, args.tag, limit=args.count)
else:
return api.tag_timeline_generator(app, user, args.tag, local=args.local, limit=args.count)
elif args.account:
return api.account_timeline_generator(app, user, args.account, limit=args.count)
elif args.list:
return api.timeline_list_generator(app, user, args.list, limit=args.count)
else:
return api.home_timeline_generator(app, user, limit=args.count)
def timeline(app, user, args, generator=None):
if not generator:
generator = get_timeline_generator(app, user, args)
while True:
try:
items = next(generator)
except StopIteration:
print_out("That's all folks.")
return
if args.reverse:
items = reversed(items)
statuses = [from_dict(Status, item) for item in items]
print_timeline(statuses)
if args.once or not sys.stdout.isatty():
break
char = input("\nContinue? [Y/n] ")
if char.lower() == "n":
break
def status(app, user, args):
response = api.fetch_status(app, user, args.status_id)
if args.json:
print(response.text)
else:
status = from_dict(Status, response.json())
print_status(status)
def thread(app, user, args):
context_response = api.context(app, user, args.status_id)
if args.json:
print(context_response.text)
else:
toot = api.fetch_status(app, user, args.status_id).json()
context = context_response.json()
statuses = chain(context["ancestors"], [toot], context["descendants"])
print_timeline(from_dict(Status, s) for s in statuses)
def post(app, user, args):
if args.editor and not sys.stdin.isatty():
raise ConsoleError("Cannot run editor if not in tty.")
if args.media and len(args.media) > 4:
raise ConsoleError("Cannot attach more than 4 files.")
media_ids = _upload_media(app, user, args)
status_text = _get_status_text(args.text, args.editor, args.media)
scheduled_at = _get_scheduled_at(args.scheduled_at, args.scheduled_in)
if not status_text and not media_ids:
raise ConsoleError("You must specify either text or media to post.")
response = api.post_status(
app, user, status_text,
visibility=args.visibility,
media_ids=media_ids,
sensitive=args.sensitive,
spoiler_text=args.spoiler_text,
in_reply_to_id=args.reply_to,
language=args.language,
scheduled_at=scheduled_at,
content_type=args.content_type,
poll_options=args.poll_option,
poll_expires_in=args.poll_expires_in,
poll_multiple=args.poll_multiple,
poll_hide_totals=args.poll_hide_totals,
)
if args.json:
print(response.text)
else:
status = response.json()
if "scheduled_at" in status:
scheduled_at = parse_datetime(status["scheduled_at"])
scheduled_at = datetime.strftime(scheduled_at, "%Y-%m-%d %H:%M:%S%z")
print_out(f"Toot scheduled for: <green>{scheduled_at}</green>")
else:
print_out(f"Toot posted: <green>{status['url']}")
delete_tmp_status_file()
def _get_status_text(text, editor, media):
isatty = sys.stdin.isatty()
if not text and not isatty:
text = sys.stdin.read().rstrip()
if isatty:
if editor:
text = editor_input(editor, text)
elif not text and not media:
print_out("Write or paste your toot. Press <yellow>{}</yellow> to post it.".format(EOF_KEY))
text = multiline_input()
return text
def _get_scheduled_at(scheduled_at, scheduled_in):
if scheduled_at:
return scheduled_at
if scheduled_in:
scheduled_at = datetime.now(timezone.utc) + timedelta(seconds=scheduled_in)
return scheduled_at.replace(microsecond=0).isoformat()
return None
def _upload_media(app, user, args):
# Match media to corresponding description and thumbnail
media = args.media or []
descriptions = args.description or []
thumbnails = args.thumbnail or []
uploaded_media = []
for idx, file in enumerate(media):
description = descriptions[idx].strip() if idx < len(descriptions) else None
thumbnail = thumbnails[idx] if idx < len(thumbnails) else None
result = _do_upload(app, user, file, description, thumbnail)
uploaded_media.append(result)
_wait_until_all_processed(app, user, uploaded_media)
return [m["id"] for m in uploaded_media]
def _wait_until_all_processed(app, user, uploaded_media):
"""
Media is uploaded asynchronously, and cannot be attached until the server
has finished processing it. This function waits for that to happen.
Once media is processed, it will have the URL populated.
"""
if all(m["url"] for m in uploaded_media):
return
# Timeout after waiting 1 minute
start_time = time()
timeout = 60
print_out("<dim>Waiting for media to finish processing...</dim>")
for media in uploaded_media:
_wait_until_processed(app, user, media, start_time, timeout)
def _wait_until_processed(app, user, media, start_time, timeout):
if media["url"]:
return
media = api.get_media(app, user, media["id"])
while not media["url"]:
sleep(1)
if time() > start_time + timeout:
raise ConsoleError(f"Media not processed by server after {timeout} seconds. Aborting.")
media = api.get_media(app, user, media["id"])
def delete(app, user, args):
response = api.delete_status(app, user, args.status_id)
if args.json:
print(response.text)
else:
print_out("<green>✓ Status deleted</green>")
def favourite(app, user, args):
response = api.favourite(app, user, args.status_id)
if args.json:
print(response.text)
else:
print_out("<green>✓ Status favourited</green>")
def unfavourite(app, user, args):
response = api.unfavourite(app, user, args.status_id)
if args.json:
print(response.text)
else:
print_out("<green>✓ Status unfavourited</green>")
def reblog(app, user, args):
response = api.reblog(app, user, args.status_id, visibility=args.visibility)
if args.json:
print(response.text)
else:
print_out("<green>✓ Status reblogged</green>")
def unreblog(app, user, args):
response = api.unreblog(app, user, args.status_id)
if args.json:
print(response.text)
else:
print_out("<green>✓ Status unreblogged</green>")
def pin(app, user, args):
response = api.pin(app, user, args.status_id)
if args.json:
print(response.text)
else:
print_out("<green>✓ Status pinned</green>")
def unpin(app, user, args):
response = api.unpin(app, user, args.status_id)
if args.json:
print(response.text)
else:
print_out("<green>✓ Status unpinned</green>")
def bookmark(app, user, args):
response = api.bookmark(app, user, args.status_id)
if args.json:
print(response.text)
else:
print_out("<green>✓ Status bookmarked</green>")
def unbookmark(app, user, args):
response = api.unbookmark(app, user, args.status_id)
if args.json:
print(response.text)
else:
print_out("<green>✓ Status unbookmarked</green>")
def bookmarks(app, user, args):
timeline(app, user, args, api.bookmark_timeline_generator(app, user, limit=args.count))
def reblogged_by(app, user, args):
response = api.reblogged_by(app, user, args.status_id)
if args.json:
print(response.text)
else:
headers = ["Account", "Display name"]
rows = [[a["acct"], a["display_name"]] for a in response.json()]
print_table(headers, rows)
def auth(app, user, args):
config_data = config.load_config()
if not config_data["users"]:
print_out("You are not logged in to any accounts")
return
active_user = config_data["active_user"]
print_out("Authenticated accounts:")
for uid, u in config_data["users"].items():
active_label = "ACTIVE" if active_user == uid else ""
print_out("* <green>{}</green> <yellow>{}</yellow>".format(uid, active_label))
path = config.get_config_file_path()
print_out("\nAuth tokens are stored in: <blue>{}</blue>".format(path))
def env(app, user, args):
print_out(f"toot {__version__}")
print_out(f"Python {sys.version}")
print_out(platform.platform())
def update_account(app, user, args):
options = [
args.avatar,
args.bot,
args.discoverable,
args.display_name,
args.header,
args.language,
args.locked,
args.note,
args.privacy,
args.sensitive,
]
if all(option is None for option in options):
raise ConsoleError("Please specify at least one option to update the account")
response = api.update_account(
app,
user,
avatar=args.avatar,
bot=args.bot,
discoverable=args.discoverable,
display_name=args.display_name,
header=args.header,
language=args.language,
locked=args.locked,
note=args.note,
privacy=args.privacy,
sensitive=args.sensitive,
)
if args.json:
print(response.text)
else:
print_out("<green>✓ Account updated</green>")
def login_cli(app, user, args):
base_url = args_get_instance(args.instance, args.scheme)
app = create_app_interactive(base_url)
login_interactive(app, args.email)
print_out()
print_out("<green>✓ Successfully logged in.</green>")
def login(app, user, args):
base_url = args_get_instance(args.instance, args.scheme)
app = create_app_interactive(base_url)
login_browser_interactive(app)
print_out()
print_out("<green>✓ Successfully logged in.</green>")
def logout(app, user, args):
user = config.load_user(args.account, throw=True)
config.delete_user(user)
print_out("<green>✓ User {} logged out</green>".format(config.user_id(user)))
def activate(app, user, args):
if not args.account:
print_out("Specify one of the following user accounts to activate:\n")
print_user_list(config.get_user_list())
return
user = config.load_user(args.account, throw=True)
config.activate_user(user)
print_out("<green>✓ User {} active</green>".format(config.user_id(user)))
def upload(app, user, args):
response = _do_upload(app, user, args.file, args.description, None)
msg = "Successfully uploaded media ID <yellow>{}</yellow>, type '<yellow>{}</yellow>'"
print_out()
print_out(msg.format(response['id'], response['type']))
print_out("URL: <green>{}</green>".format(response['url']))
print_out("Preview URL: <green>{}</green>".format(response['preview_url']))
def search(app, user, args):
response = api.search(app, user, args.query, args.resolve)
if args.json:
print(response.text)
else:
print_search_results(response.json())
def _do_upload(app, user, file, description, thumbnail):
print_out("Uploading media: <green>{}</green>".format(file.name))
return api.upload_media(app, user, file, description=description, thumbnail=thumbnail)
def follow(app, user, args):
account = api.find_account(app, user, args.account)
response = api.follow(app, user, account["id"])
if args.json:
print(response.text)
else:
print_out(f"<green>✓ You are now following {args.account}</green>")
def unfollow(app, user, args):
account = api.find_account(app, user, args.account)
response = api.unfollow(app, user, account["id"])
if args.json:
print(response.text)
else:
print_out(f"<green>✓ You are no longer following {args.account}</green>")
def following(app, user, args):
account = args.account or user.username
account = api.find_account(app, user, account)
accounts = api.following(app, user, account["id"])
if args.json:
print(json.dumps(accounts))
else:
print_acct_list(accounts)
def followers(app, user, args):
account = args.account or user.username
account = api.find_account(app, user, account)
accounts = api.followers(app, user, account["id"])
if args.json:
print(json.dumps(accounts))
else:
print_acct_list(accounts)
def tags_follow(app, user, args):
tn = args.tag_name if not args.tag_name.startswith("#") else args.tag_name[1:]
api.follow_tag(app, user, tn)
print_out("<green>✓ You are now following #{}</green>".format(tn))
def tags_unfollow(app, user, args):
tn = args.tag_name if not args.tag_name.startswith("#") else args.tag_name[1:]
api.unfollow_tag(app, user, tn)
print_out("<green>✓ You are no longer following #{}</green>".format(tn))
def tags_followed(app, user, args):
response = api.followed_tags(app, user)
print_tag_list(response)
def lists(app, user, args):
lists = api.get_lists(app, user)
if lists:
print_lists(lists)
else:
print_out("You have no lists defined.")
def list_accounts(app, user, args):
list_id = _get_list_id(app, user, args)
response = api.get_list_accounts(app, user, list_id)
print_list_accounts(response)
def list_create(app, user, args):
api.create_list(app, user, title=args.title, replies_policy=args.replies_policy)
print_out(f"<green>✓ List \"{args.title}\" created.</green>")
def list_delete(app, user, args):
list_id = _get_list_id(app, user, args)
api.delete_list(app, user, list_id)
print_out(f"<green>✓ List \"{args.title if args.title else args.id}\"</green> <red>deleted.</red>")
def list_add(app, user, args):
list_id = _get_list_id(app, user, args)
account = api.find_account(app, user, args.account)
try:
api.add_accounts_to_list(app, user, list_id, [account['id']])
except Exception as ex:
# if we failed to add the account, try to give a
# more specific error message than "record not found"
my_accounts = api.followers(app, user, account['id'])
found = False
if my_accounts:
for my_account in my_accounts:
if my_account['id'] == account['id']:
found = True
break
if found is False:
print_out(f"<red>You must follow @{account['acct']} before adding this account to a list.</red>")
else:
print_out(f"<red>{ex}</red>")
return
print_out(f"<green>✓ Added account \"{args.account}\"</green>")
def list_remove(app, user, args):
list_id = _get_list_id(app, user, args)
account = api.find_account(app, user, args.account)
api.remove_accounts_from_list(app, user, list_id, [account['id']])
print_out(f"<green>✓ Removed account \"{args.account}\"</green>")
def _get_list_id(app, user, args):
list_id = args.id or api.find_list_id(app, user, args.title)
if not list_id:
raise ConsoleError("List not found")
return list_id
def mute(app, user, args):
account = api.find_account(app, user, args.account)
response = api.mute(app, user, account['id'])
if args.json:
print(response.text)
else:
print_out("<green>✓ You have muted {}</green>".format(args.account))
def unmute(app, user, args):
account = api.find_account(app, user, args.account)
response = api.unmute(app, user, account['id'])
if args.json:
print(response.text)
else:
print_out("<green>✓ {} is no longer muted</green>".format(args.account))
def muted(app, user, args):
response = api.muted(app, user)
if args.json:
print(json.dumps(response))
else:
if len(response) > 0:
print("Muted accounts:")
print_acct_list(response)
else:
print("No accounts muted")
def block(app, user, args):
account = api.find_account(app, user, args.account)
response = api.block(app, user, account['id'])
if args.json:
print(response.text)
else:
print_out("<green>✓ You are now blocking {}</green>".format(args.account))
def unblock(app, user, args):
account = api.find_account(app, user, args.account)
response = api.unblock(app, user, account['id'])
if args.json:
print(response.text)
else:
print_out("<green>✓ {} is no longer blocked</green>".format(args.account))
def blocked(app, user, args):
response = api.blocked(app, user)
if args.json:
print(json.dumps(response))
else:
if len(response) > 0:
print("Blocked accounts:")
print_acct_list(response)
else:
print("No accounts blocked")
def whoami(app, user, args):
response = api.verify_credentials(app, user)
if args.json:
print(response.text)
else:
account = from_dict(Account, response.json())
print_account(account)
def whois(app, user, args):
account = api.find_account(app, user, args.account)
# Here it's not possible to avoid parsing json since it's needed to find the account.
if args.json:
print(json.dumps(account))
else:
account = from_dict(Account, account)
print_account(account)
def instance(app, user, args):
default = app.base_url if app else None
base_url = args_get_instance(args.instance, args.scheme, default)
if not base_url:
raise ConsoleError("Please specify an instance.")
try:
response = api.get_instance(base_url)
except ApiError:
raise ConsoleError(
f"Instance not found at {base_url}.\n"
"The given domain probably does not host a Mastodon instance."
)
if args.json:
print(response.text)
else:
instance = from_dict(Instance, response.json())
print_instance(instance)
def notifications(app, user, args):
if args.clear:
api.clear_notifications(app, user)
print_out("<green>Cleared notifications</green>")
return
exclude = []
if args.mentions:
# Filter everything except mentions
# https://docs.joinmastodon.org/methods/notifications/
exclude = ["follow", "favourite", "reblog", "poll", "follow_request"]
notifications = api.get_notifications(app, user, exclude_types=exclude)
if not notifications:
print_out("<yellow>No notification</yellow>")
return
if args.reverse:
notifications = reversed(notifications)
notifications = [from_dict(Notification, n) for n in notifications]
print_notifications(notifications)
def tui(app, user, args):
from .tui.app import TUI
TUI.create(app, user, args).run()

View File

@ -3,10 +3,10 @@ import os
from functools import wraps
from os.path import dirname, join
from typing import Optional
from toot import User, App, get_config_dir
from toot.exceptions import ConsoleError
from toot.output import print_out
TOOT_CONFIG_FILE_NAME = "config.json"
@ -29,8 +29,6 @@ def make_config(path):
"active_user": None,
}
print_out("Creating config file at <blue>{}</blue>".format(path))
# Ensure dir exists
os.makedirs(dirname(path), exist_ok=True)
@ -41,6 +39,10 @@ def make_config(path):
def load_config():
# Just to prevent accidentally running tests on production
if os.environ.get("TOOT_TESTING"):
raise Exception("Tests should not access the config file!")
path = get_config_file_path()
if not os.path.exists(path):
@ -85,7 +87,7 @@ def get_user_app(user_id):
return extract_user_app(load_config(), user_id)
def load_app(instance):
def load_app(instance: str) -> Optional[App]:
config = load_config()
if instance in config['apps']:
return App(**config['apps'][instance])

View File

@ -1,966 +0,0 @@
import logging
import os
import re
import shutil
import sys
from argparse import ArgumentParser, FileType, ArgumentTypeError, Action
from collections import namedtuple
from itertools import chain
from toot import config, commands, CLIENT_NAME, CLIENT_WEBSITE, __version__, settings
from toot.exceptions import ApiError, ConsoleError
from toot.output import print_out, print_err
from toot.settings import get_setting
VISIBILITY_CHOICES = ["public", "unlisted", "private", "direct"]
VISIBILITY_CHOICES_STR = ", ".join(f"'{v}'" for v in VISIBILITY_CHOICES)
PRIVACY_CHOICES = ["public", "unlisted", "private"]
PRIVACY_CHOICES_STR = ", ".join(f"'{v}'" for v in PRIVACY_CHOICES)
class BooleanOptionalAction(Action):
"""
Backported from argparse. This action is available since Python 3.9.
https://github.com/python/cpython/blob/3.11/Lib/argparse.py
"""
def __init__(self,
option_strings,
dest,
default=None,
type=None,
choices=None,
required=False,
help=None,
metavar=None):
_option_strings = []
for option_string in option_strings:
_option_strings.append(option_string)
if option_string.startswith('--'):
option_string = '--no-' + option_string[2:]
_option_strings.append(option_string)
super().__init__(
option_strings=_option_strings,
dest=dest,
nargs=0,
default=default,
type=type,
choices=choices,
required=required,
help=help,
metavar=metavar)
def __call__(self, parser, namespace, values, option_string=None):
if option_string in self.option_strings:
setattr(namespace, self.dest, not option_string.startswith('--no-'))
def format_usage(self):
return ' | '.join(self.option_strings)
def get_default_visibility():
return os.getenv("TOOT_POST_VISIBILITY", "public")
def language(value):
"""Validates the language parameter"""
if len(value) != 2:
raise ArgumentTypeError(
"Invalid language. Expected a 2 letter abbreviation according to "
"the ISO 639-1 standard."
)
return value
def visibility(value):
"""Validates the visibility parameter"""
if value not in VISIBILITY_CHOICES:
raise ValueError("Invalid visibility value")
return value
def privacy(value):
"""Validates the privacy parameter"""
if value not in PRIVACY_CHOICES:
raise ValueError(f"Invalid privacy value. Expected one of {PRIVACY_CHOICES_STR}.")
return value
def timeline_count(value):
n = int(value)
if not 0 < n <= 20:
raise ArgumentTypeError("Number of toots should be between 1 and 20.")
return n
DURATION_UNITS = {
"m": 60,
"h": 60 * 60,
"d": 60 * 60 * 24,
}
DURATION_EXAMPLES = """e.g. "1 day", "2 hours 30 minutes", "5 minutes 30
seconds" or any combination of above. Shorthand: "1d", "2h30m", "5m30s\""""
def duration(value: str):
match = re.match(r"""^
(([0-9]+)\s*(days|day|d))?\s*
(([0-9]+)\s*(hours|hour|h))?\s*
(([0-9]+)\s*(minutes|minute|m))?\s*
(([0-9]+)\s*(seconds|second|s))?\s*
$""", value, re.X)
if not match:
raise ArgumentTypeError(f"Invalid duration: {value}")
days = match.group(2)
hours = match.group(5)
minutes = match.group(8)
seconds = match.group(11)
days = int(match.group(2) or 0) * 60 * 60 * 24
hours = int(match.group(5) or 0) * 60 * 60
minutes = int(match.group(8) or 0) * 60
seconds = int(match.group(11) or 0)
duration = days + hours + minutes + seconds
if duration == 0:
raise ArgumentTypeError("Empty duration")
return duration
def editor(value):
if not value:
raise ArgumentTypeError(
"Editor not specified in --editor option and $EDITOR environment "
"variable not set."
)
# Check editor executable exists
exe = shutil.which(value)
if not exe:
raise ArgumentTypeError("Editor `{}` not found".format(value))
return exe
Command = namedtuple("Command", ["name", "description", "require_auth", "arguments"])
# Arguments added to every command
common_args = [
(["--no-color"], {
"help": "don't use ANSI colors in output",
"action": 'store_true',
"default": False,
}),
(["--quiet"], {
"help": "don't write to stdout on success",
"action": 'store_true',
"default": False,
}),
(["--debug"], {
"help": "show debug log in console",
"action": 'store_true',
"default": False,
}),
(["--verbose"], {
"help": "show extra detail in debug log; used with --debug",
"action": 'store_true',
"default": False,
}),
]
# Arguments added to commands which require authentication
common_auth_args = [
(["-u", "--using"], {
"help": "the account to use, overrides active account",
}),
]
account_arg = (["account"], {
"help": "account name, e.g. 'Gargron@mastodon.social'",
})
optional_account_arg = (["account"], {
"nargs": "?",
"help": "account name, e.g. 'Gargron@mastodon.social'",
})
instance_arg = (["-i", "--instance"], {
"type": str,
"help": 'mastodon instance to log into e.g. "mastodon.social"',
})
email_arg = (["-e", "--email"], {
"type": str,
"help": 'email address to log in with',
})
scheme_arg = (["--disable-https"], {
"help": "disable HTTPS and use insecure HTTP",
"dest": "scheme",
"default": "https",
"action": "store_const",
"const": "http",
})
status_id_arg = (["status_id"], {
"help": "ID of the status",
"type": str,
})
visibility_arg = (["-v", "--visibility"], {
"type": visibility,
"default": get_default_visibility(),
"help": f"Post visibility. One of: {VISIBILITY_CHOICES_STR}. Defaults to "
f"'{get_default_visibility()}' which can be overridden by setting "
"the TOOT_POST_VISIBILITY environment variable",
})
tag_arg = (["tag_name"], {
"type": str,
"help": "tag name, e.g. Caturday, or \"#Caturday\"",
})
json_arg = (["--json"], {
"action": "store_true",
"default": False,
"help": "print json instead of plaintext",
})
# Arguments for selecting a timeline (see `toot.commands.get_timeline_generator`)
common_timeline_args = [
(["-p", "--public"], {
"action": "store_true",
"default": False,
"help": "show public timeline (does not require auth)",
}),
(["-t", "--tag"], {
"type": str,
"help": "show hashtag timeline (does not require auth)",
}),
(["-a", "--account"], {
"type": str,
"help": "show timeline for the given account",
}),
(["-l", "--local"], {
"action": "store_true",
"default": False,
"help": "show only statuses from local instance (public and tag timelines only)",
}),
(["-i", "--instance"], {
"type": str,
"help": "mastodon instance from which to read (public and tag timelines only)",
}),
(["--list"], {
"type": str,
"help": "show timeline for given list.",
}),
]
timeline_and_bookmark_args = [
(["-c", "--count"], {
"type": timeline_count,
"help": "number of toots to show per page (1-20, default 10).",
"default": 10,
}),
(["-r", "--reverse"], {
"action": "store_true",
"default": False,
"help": "Reverse the order of the shown timeline (to new posts at the bottom)",
}),
(["-1", "--once"], {
"action": "store_true",
"default": False,
"help": "Only show the first <count> toots, do not prompt to continue.",
}),
]
timeline_args = common_timeline_args + timeline_and_bookmark_args
AUTH_COMMANDS = [
Command(
name="login",
description="Log into a mastodon instance using your browser (recommended)",
arguments=[instance_arg, scheme_arg],
require_auth=False,
),
Command(
name="login_cli",
description="Log in from the console, does NOT support two factor authentication",
arguments=[instance_arg, email_arg, scheme_arg],
require_auth=False,
),
Command(
name="activate",
description="Switch between logged in accounts.",
arguments=[optional_account_arg],
require_auth=False,
),
Command(
name="logout",
description="Log out, delete stored access keys",
arguments=[account_arg],
require_auth=False,
),
Command(
name="auth",
description="Show logged in accounts and instances",
arguments=[],
require_auth=False,
),
Command(
name="env",
description="Print environment information for inclusion in bug reports.",
arguments=[],
require_auth=False,
),
Command(
name="update_account",
description="Update your account details",
arguments=[
(["--display-name"], {
"type": str,
"help": "The display name to use for the profile.",
}),
(["--note"], {
"type": str,
"help": "The account bio.",
}),
(["--avatar"], {
"type": FileType("rb"),
"help": "Path to the avatar image to set.",
}),
(["--header"], {
"type": FileType("rb"),
"help": "Path to the header image to set.",
}),
(["--bot"], {
"action": BooleanOptionalAction,
"help": "Whether the account has a bot flag.",
}),
(["--discoverable"], {
"action": BooleanOptionalAction,
"help": "Whether the account should be shown in the profile directory.",
}),
(["--locked"], {
"action": BooleanOptionalAction,
"help": "Whether manual approval of follow requests is required.",
}),
(["--privacy"], {
"type": privacy,
"help": f"Default post privacy for authored statuses. One of: {PRIVACY_CHOICES_STR}."
}),
(["--sensitive"], {
"action": BooleanOptionalAction,
"help": "Whether to mark authored statuses as sensitive by default."
}),
(["--language"], {
"type": language,
"help": "Default language to use for authored statuses (ISO 639-1)."
}),
json_arg,
],
require_auth=True,
),
]
TUI_COMMANDS = [
Command(
name="tui",
description="Launches the toot terminal user interface",
arguments=[
(["--relative-datetimes"], {
"action": "store_true",
"default": False,
"help": "Show relative datetimes in status list.",
}),
],
require_auth=True,
),
]
READ_COMMANDS = [
Command(
name="whoami",
description="Display logged in user details",
arguments=[json_arg],
require_auth=True,
),
Command(
name="whois",
description="Display account details",
arguments=[
(["account"], {
"help": "account name or numeric ID"
}),
json_arg,
],
require_auth=True,
),
Command(
name="notifications",
description="Notifications for logged in user",
arguments=[
(["--clear"], {
"help": "delete all notifications from the server",
"action": 'store_true',
"default": False,
}),
(["-r", "--reverse"], {
"action": "store_true",
"default": False,
"help": "Reverse the order of the shown notifications (newest on top)",
}),
(["-m", "--mentions"], {
"action": "store_true",
"default": False,
"help": "Only print mentions",
})
],
require_auth=True,
),
Command(
name="instance",
description="Display instance details",
arguments=[
(["instance"], {
"help": "instance domain (e.g. 'mastodon.social') or blank to use current",
"nargs": "?",
}),
scheme_arg,
json_arg,
],
require_auth=False,
),
Command(
name="search",
description="Search for users or hashtags",
arguments=[
(["query"], {
"help": "the search query",
}),
(["-r", "--resolve"], {
"action": 'store_true',
"default": False,
"help": "Resolve non-local accounts",
}),
json_arg,
],
require_auth=True,
),
Command(
name="thread",
description="Show toot thread items",
arguments=[
(["status_id"], {
"help": "Show thread for toot.",
}),
json_arg,
],
require_auth=True,
),
Command(
name="status",
description="Show a single status",
arguments=[
(["status_id"], {
"help": "ID of the status to show.",
}),
json_arg,
],
require_auth=True,
),
Command(
name="timeline",
description="Show recent items in a timeline (home by default)",
arguments=timeline_args,
require_auth=True,
),
Command(
name="bookmarks",
description="Show bookmarked posts",
arguments=timeline_and_bookmark_args,
require_auth=True,
),
]
POST_COMMANDS = [
Command(
name="post",
description="Post a status text to your timeline",
arguments=[
(["text"], {
"help": "The status text to post.",
"nargs": "?",
}),
(["-m", "--media"], {
"action": "append",
"type": FileType("rb"),
"help": "path to the media file to attach (specify multiple "
"times to attach up to 4 files)"
}),
(["-d", "--description"], {
"action": "append",
"type": str,
"help": "plain-text description of the media for accessibility "
"purposes, one per attached media"
}),
(["--thumbnail"], {
"action": "append",
"type": FileType("rb"),
"help": "path to an image file to serve as media thumbnail, "
"one per attached media"
}),
visibility_arg,
(["-s", "--sensitive"], {
"action": 'store_true',
"default": False,
"help": "mark the media as NSFW",
}),
(["-p", "--spoiler-text"], {
"type": str,
"help": "text to be shown as a warning before the actual content",
}),
(["-r", "--reply-to"], {
"type": str,
"help": "local ID of the status you want to reply to",
}),
(["-l", "--language"], {
"type": language,
"help": "ISO 639-1 language code of the toot, to skip automatic detection",
}),
(["-e", "--editor"], {
"type": editor,
"nargs": "?",
"const": os.getenv("EDITOR", ""), # option given without value
"help": "Specify an editor to compose your toot, "
"defaults to editor defined in $EDITOR env variable.",
}),
(["--scheduled-at"], {
"type": str,
"help": "ISO 8601 Datetime at which to schedule a status. Must "
"be at least 5 minutes in the future.",
}),
(["--scheduled-in"], {
"type": duration,
"help": f"""Schedule the toot to be posted after a given amount
of time, {DURATION_EXAMPLES}. Must be at least 5
minutes.""",
}),
(["-t", "--content-type"], {
"type": str,
"help": "MIME type for the status text (not supported on all instances)",
}),
(["--poll-option"], {
"action": "append",
"type": str,
"help": "Possible answer to the poll"
}),
(["--poll-expires-in"], {
"type": duration,
"help": f"""Duration that the poll should be open,
{DURATION_EXAMPLES}. Defaults to 24h.""",
"default": 24 * 60 * 60,
}),
(["--poll-multiple"], {
"action": "store_true",
"default": False,
"help": "Allow multiple answers to be selected."
}),
(["--poll-hide-totals"], {
"action": "store_true",
"default": False,
"help": "Hide vote counts until the poll ends. Defaults to false."
}),
json_arg,
],
require_auth=True,
),
Command(
name="upload",
description="Upload an image or video file",
arguments=[
(["file"], {
"help": "Path to the file to upload",
"type": FileType('rb')
}),
(["-d", "--description"], {
"type": str,
"help": "plain-text description of the media for accessibility purposes"
}),
],
require_auth=True,
),
]
STATUS_COMMANDS = [
Command(
name="delete",
description="Delete a status",
arguments=[status_id_arg, json_arg],
require_auth=True,
),
Command(
name="favourite",
description="Favourite a status",
arguments=[status_id_arg, json_arg],
require_auth=True,
),
Command(
name="unfavourite",
description="Unfavourite a status",
arguments=[status_id_arg, json_arg],
require_auth=True,
),
Command(
name="reblog",
description="Reblog a status",
arguments=[status_id_arg, visibility_arg, json_arg],
require_auth=True,
),
Command(
name="unreblog",
description="Unreblog a status",
arguments=[status_id_arg, json_arg],
require_auth=True,
),
Command(
name="reblogged_by",
description="Show accounts that reblogged the status",
arguments=[status_id_arg, json_arg],
require_auth=False,
),
Command(
name="pin",
description="Pin a status",
arguments=[status_id_arg, json_arg],
require_auth=True,
),
Command(
name="unpin",
description="Unpin a status",
arguments=[status_id_arg, json_arg],
require_auth=True,
),
Command(
name="bookmark",
description="Bookmark a status",
arguments=[status_id_arg, json_arg],
require_auth=True,
),
Command(
name="unbookmark",
description="Unbookmark a status",
arguments=[status_id_arg, json_arg],
require_auth=True,
),
]
ACCOUNTS_COMMANDS = [
Command(
name="follow",
description="Follow an account",
arguments=[account_arg, json_arg],
require_auth=True,
),
Command(
name="unfollow",
description="Unfollow an account",
arguments=[account_arg, json_arg],
require_auth=True,
),
Command(
name="following",
description="List accounts followed by the given account, " +
"or your account if no account given",
arguments=[optional_account_arg, json_arg],
require_auth=True,
),
Command(
name="followers",
description="List accounts following the given account, " +
"or your account if no account given",
arguments=[optional_account_arg, json_arg],
require_auth=True,
),
Command(
name="mute",
description="Mute an account",
arguments=[account_arg, json_arg],
require_auth=True,
),
Command(
name="unmute",
description="Unmute an account",
arguments=[account_arg, json_arg],
require_auth=True,
),
Command(
name="muted",
description="List muted accounts",
arguments=[json_arg],
require_auth=True,
),
Command(
name="block",
description="Block an account",
arguments=[account_arg, json_arg],
require_auth=True,
),
Command(
name="unblock",
description="Unblock an account",
arguments=[account_arg, json_arg],
require_auth=True,
),
Command(
name="blocked",
description="List blocked accounts",
arguments=[json_arg],
require_auth=True,
),
]
TAG_COMMANDS = [
Command(
name="tags_followed",
description="List hashtags you follow",
arguments=[],
require_auth=True,
),
Command(
name="tags_follow",
description="Follow a hashtag",
arguments=[tag_arg],
require_auth=True,
),
Command(
name="tags_unfollow",
description="Unfollow a hashtag",
arguments=[tag_arg],
require_auth=True,
),
]
LIST_COMMANDS = [
Command(
name="lists",
description="List all lists",
arguments=[],
require_auth=True,
),
Command(
name="list_accounts",
description="List the accounts in a list",
arguments=[
(["--id"], {
"type": str,
"help": "ID of the list"
}),
(["title"], {
"type": str,
"nargs": "?",
"help": "title of the list"
}),
],
require_auth=True,
),
Command(
name="list_create",
description="Create a list",
arguments=[
(["title"], {
"type": str,
"help": "title of the list"
}),
(["--replies-policy"], {
"type": str,
"help": "replies policy: 'followed', 'list', or 'none' (defaults to 'none')"
}),
],
require_auth=True,
),
Command(
name="list_delete",
description="Delete a list",
arguments=[
(["--id"], {
"type": str,
"help": "ID of the list"
}),
(["title"], {
"type": str,
"nargs": "?",
"help": "title of the list"
}),
],
require_auth=True,
),
Command(
name="list_add",
description="Add account to list",
arguments=[
(["--id"], {
"type": str,
"help": "ID of the list"
}),
(["title"], {
"type": str,
"nargs": "?",
"help": "title of the list"
}),
(["account"], {
"type": str,
"help": "Account to add"
}),
],
require_auth=True,
),
Command(
name="list_remove",
description="Remove account from list",
arguments=[
(["--id"], {
"type": str,
"help": "ID of the list"
}),
(["title"], {
"type": str,
"nargs": "?",
"help": "title of the list"
}),
(["account"], {
"type": str,
"help": "Account to remove"
}),
],
require_auth=True,
),
]
COMMAND_GROUPS = [
("Authentication", AUTH_COMMANDS),
("TUI", TUI_COMMANDS),
("Read", READ_COMMANDS),
("Post", POST_COMMANDS),
("Status", STATUS_COMMANDS),
("Accounts", ACCOUNTS_COMMANDS),
("Hashtags", TAG_COMMANDS),
("Lists", LIST_COMMANDS),
]
COMMANDS = list(chain(*[commands for _, commands in COMMAND_GROUPS]))
def print_usage():
max_name_len = max(len(name) for name, _ in COMMAND_GROUPS)
print_out("<green>{}</green>".format(CLIENT_NAME))
print_out("<blue>v{}</blue>".format(__version__))
for name, cmds in COMMAND_GROUPS:
print_out("")
print_out(name + ":")
for cmd in cmds:
cmd_name = cmd.name.ljust(max_name_len + 2)
print_out(" <yellow>toot {}</yellow> {}".format(cmd_name, cmd.description))
print_out("")
print_out("To get help for each command run:")
print_out(" <yellow>toot \\<command> --help</yellow>")
print_out("")
print_out("<green>{}</green>".format(CLIENT_WEBSITE))
def get_argument_parser(name, command):
parser = ArgumentParser(
prog='toot %s' % name,
description=command.description,
epilog=CLIENT_WEBSITE)
combined_args = command.arguments + common_args
if command.require_auth:
combined_args += common_auth_args
defaults = get_setting(f"commands.{name}", dict, {})
for args, kwargs in combined_args:
# Set default value from settings if exists
default = get_default_value(defaults, args)
if default is not None:
kwargs["default"] = default
parser.add_argument(*args, **kwargs)
return parser
def get_default_value(defaults, args):
# Hacky way to determine command name from argparse args
name = args[-1].lstrip("-").replace("-", "_")
return defaults.get(name)
def run_command(app, user, name, args):
command = next((c for c in COMMANDS if c.name == name), None)
if not command:
print_err(f"Unknown command '{name}'")
print_out("Run <yellow>toot --help</yellow> to show a list of available commands.")
return
parser = get_argument_parser(name, command)
parsed_args = parser.parse_args(args)
# Override the active account if 'using' option is given
if command.require_auth and parsed_args.using:
user, app = config.get_user_app(parsed_args.using)
if not user or not app:
raise ConsoleError("User '{}' not found".format(parsed_args.using))
if command.require_auth and (not user or not app):
print_err("This command requires that you are logged in.")
print_err("Please run `toot login` first.")
return
fn = commands.__dict__.get(name)
if not fn:
raise NotImplementedError("Command '{}' does not have an implementation.".format(name))
return fn(app, user, parsed_args)
def main():
if settings.get_debug():
filename = settings.get_debug_file()
logging.basicConfig(level=logging.DEBUG, filename=filename)
logging.getLogger("urllib3").setLevel(logging.INFO)
command_name = sys.argv[1] if len(sys.argv) > 1 else None
args = sys.argv[2:]
if not command_name or command_name == "--help":
return print_usage()
user, app = config.get_active_user_app()
try:
run_command(app, user, command_name, args)
except (ConsoleError, ApiError) as e:
print_err(str(e))
sys.exit(1)
except KeyboardInterrupt:
pass

View File

@ -409,6 +409,42 @@ class Relationship:
note: str
@dataclass
class TagHistory:
"""
Usage statistics for given days (typically the past week).
https://docs.joinmastodon.org/entities/Tag/#history
"""
day: str
uses: str
accounts: str
@dataclass
class Tag:
"""
Represents a hashtag used within the content of a status.
https://docs.joinmastodon.org/entities/Tag/
"""
name: str
url: str
history: List[TagHistory]
following: Optional[bool]
@dataclass
class FeaturedTag:
"""
Represents a hashtag that is featured on a profile.
https://docs.joinmastodon.org/entities/FeaturedTag/
"""
id: str
name: str
url: str
statuses_count: int
last_status_at: datetime
# Generic data class instance
T = TypeVar("T")

View File

@ -1,4 +1,7 @@
class ApiError(Exception):
from click import ClickException
class ApiError(ClickException):
"""Raised when an API request fails for whatever reason."""
@ -10,5 +13,5 @@ class AuthenticationError(ApiError):
"""Raised when login fails."""
class ConsoleError(Exception):
class ConsoleError(ClickException):
"""Raised when an error occurs which needs to be show to the user."""

View File

@ -1,227 +1,122 @@
import os
import click
import re
import sys
import textwrap
import shutil
from functools import lru_cache
from toot import settings
from toot.utils import get_text, html_to_paragraphs
from toot.entities import Account, Instance, Notification, Poll, Status
from toot.utils import get_text, html_to_paragraphs
from toot.wcstring import wc_wrap
from typing import Iterable, List
from typing import Any, Generator, Iterable, List
from wcwidth import wcswidth
STYLES = {
'reset': '\033[0m',
'bold': '\033[1m',
'dim': '\033[2m',
'italic': '\033[3m',
'underline': '\033[4m',
'red': '\033[91m',
'green': '\033[92m',
'yellow': '\033[93m',
'blue': '\033[94m',
'magenta': '\033[95m',
'cyan': '\033[96m',
}
STYLE_TAG_PATTERN = re.compile(r"""
(?<!\\) # not preceeded by a backslash - allows escaping
< # literal
(/)? # optional closing - first group
(.*?) # style names - ungreedy - second group
> # literal
""", re.X)
DEFAULT_WIDTH = 80
def colorize(message):
"""
Replaces style tags in `message` with ANSI escape codes.
Markup is inspired by HTML, but you can use multiple words pre tag, e.g.:
<red bold>alert!</red bold> a thing happened
Empty closing tag will reset all styes:
<red bold>alert!</> a thing happened
Styles can be nested:
<red>red <underline>red and underline</underline> red</red>
"""
def _codes(styles):
for style in styles:
yield STYLES.get(style, "")
def _generator(message):
# A list is used instead of a set because we want to keep style order
# This allows nesting colors, e.g. "<blue>foo<red>bar</red>baz</blue>"
position = 0
active_styles = []
for match in re.finditer(STYLE_TAG_PATTERN, message):
is_closing = bool(match.group(1))
styles = match.group(2).strip().split()
start, end = match.span()
# Replace backslash for escaped <
yield message[position:start].replace("\\<", "<")
if is_closing:
yield STYLES["reset"]
# Empty closing tag resets all styles
if styles == []:
active_styles = []
else:
active_styles = [s for s in active_styles if s not in styles]
yield from _codes(active_styles)
else:
active_styles = active_styles + styles
yield from _codes(styles)
position = end
if position == 0:
# Nothing matched, yield the original string
yield message
else:
# Yield the remaining fragment
yield message[position:]
# Reset styles at the end to prevent leaking
yield STYLES["reset"]
return "".join(_generator(message))
def get_max_width() -> int:
return click.get_current_context().max_content_width or DEFAULT_WIDTH
def strip_tags(message):
return re.sub(STYLE_TAG_PATTERN, "", message)
def get_terminal_width() -> int:
return shutil.get_terminal_size().columns
@lru_cache(maxsize=None)
def use_ansi_color():
"""Returns True if ANSI color codes should be used."""
# Windows doesn't support color unless ansicon is installed
# See: http://adoxa.altervista.org/ansicon/
if sys.platform == 'win32' and 'ANSICON' not in os.environ:
return False
# Don't show color if stdout is not a tty, e.g. if output is piped on
if not sys.stdout.isatty():
return False
# Don't show color if explicitly specified in options
if "--no-color" in sys.argv:
return False
# Check in settings
color = settings.get_setting("common.color", bool)
if color is not None:
return color
# Use color by default
return True
def get_width() -> int:
return min(get_terminal_width(), get_max_width())
def print_out(*args, **kwargs):
if not settings.get_quiet():
args = [colorize(a) if use_ansi_color() else strip_tags(a) for a in args]
print(*args, **kwargs)
def print_err(*args, **kwargs):
args = [f"<red>{a}</red>" for a in args]
args = [colorize(a) if use_ansi_color() else strip_tags(a) for a in args]
print(*args, file=sys.stderr, **kwargs)
def print_warning(text: str):
click.secho(f"Warning: {text}", fg="yellow", err=True)
def print_instance(instance: Instance):
print_out(f"<green>{instance.title}</green>")
print_out(f"<blue>{instance.uri}</blue>")
print_out(f"running Mastodon {instance.version}")
print_out()
width = get_width()
click.echo(instance_to_text(instance, width))
def instance_to_text(instance: Instance, width: int) -> str:
return "\n".join(instance_lines(instance, width))
def instance_lines(instance: Instance, width: int) -> Generator[str, None, None]:
yield f"{green(instance.title)}"
yield f"{blue(instance.uri)}"
yield f"running Mastodon {instance.version}"
yield ""
if instance.description:
for paragraph in re.split(r"[\r\n]+", instance.description.strip()):
paragraph = get_text(paragraph)
print_out(textwrap.fill(paragraph, width=80))
print_out()
yield textwrap.fill(paragraph, width=width)
yield ""
if instance.rules:
print_out("Rules:")
yield "Rules:"
for ordinal, rule in enumerate(instance.rules):
ordinal = f"{ordinal + 1}."
lines = textwrap.wrap(rule.text, 80 - len(ordinal))
lines = textwrap.wrap(rule.text, width - len(ordinal))
first = True
for line in lines:
if first:
print_out(f"{ordinal} {line}")
yield f"{ordinal} {line}"
first = False
else:
print_out(f"{' ' * len(ordinal)} {line}")
print_out()
yield f"{' ' * len(ordinal)} {line}"
yield ""
contact = instance.contact_account
if contact:
print_out(f"Contact: {contact.display_name} @{contact.acct}")
yield f"Contact: {contact.display_name} @{contact.acct}"
def print_account(account: Account):
print_out(f"<green>@{account.acct}</green> {account.display_name}")
def print_account(account: Account) -> None:
width = get_width()
click.echo(account_to_text(account, width))
def account_to_text(account: Account, width: int) -> str:
return "\n".join(account_lines(account, width))
def account_lines(account: Account, width: int) -> Generator[str, None, None]:
acct = f"@{account.acct}"
since = account.created_at.strftime("%Y-%m-%d")
yield f"{green(acct)} {account.display_name}"
if account.note:
print_out("")
print_html(account.note)
yield ""
yield from html_lines(account.note, width)
since = account.created_at.strftime('%Y-%m-%d')
print_out("")
print_out(f"ID: <green>{account.id}</green>")
print_out(f"Since: <green>{since}</green>")
print_out("")
print_out(f"Followers: <yellow>{account.followers_count}</yellow>")
print_out(f"Following: <yellow>{account.following_count}</yellow>")
print_out(f"Statuses: <yellow>{account.statuses_count}</yellow>")
yield ""
yield f"ID: {green(account.id)}"
yield f"Since: {green(since)}"
yield ""
yield f"Followers: {yellow(account.followers_count)}"
yield f"Following: {yellow(account.following_count)}"
yield f"Statuses: {yellow(account.statuses_count)}"
if account.fields:
for field in account.fields:
name = field.name.title()
print_out(f'\n<yellow>{name}</yellow>:')
print_html(field.value)
yield f'\n{yellow(name)}:'
yield from html_lines(field.value, width)
if field.verified_at:
print_out("<green>✓ Verified</green>")
yield green("✓ Verified")
print_out("")
print_out(account.url)
HASHTAG_PATTERN = re.compile(r'(?<!\w)(#\w+)\b')
def highlight_hashtags(line):
return re.sub(HASHTAG_PATTERN, '<cyan>\\1</cyan>', line)
yield ""
yield account.url
def print_acct_list(accounts):
for account in accounts:
print_out(f"* <green>@{account['acct']}</green> {account['display_name']}")
def print_user_list(users):
for user in users:
print_out(f"* {user}")
acct = green(f"@{account['acct']}")
click.echo(f"* {acct} {account['display_name']}")
def print_tag_list(tags):
if tags:
for tag in tags:
print_out(f"* <green>#{tag['name']}\t</green>{tag['url']}")
else:
print_out("You're not following any hashtags.")
for tag in tags:
click.echo(f"* {format_tag_name(tag)}\t{tag['url']}")
def print_lists(lists):
@ -234,20 +129,17 @@ def print_table(headers: List[str], data: List[List[str]]):
widths = [[len(cell) for cell in row] for row in data + [headers]]
widths = [max(width) for width in zip(*widths)]
def style(string, tag):
return f"<{tag}>{string}</{tag}>" if tag else string
def print_row(row, tag=None):
def print_row(row):
for idx, cell in enumerate(row):
width = widths[idx]
print_out(style(cell.ljust(width), tag), end="")
print_out(" ", end="")
print_out()
click.echo(cell.ljust(width), nl=False)
click.echo(" ", nl=False)
click.echo()
underlines = ["-" * width for width in widths]
print_row(headers, "bold")
print_row(underlines, "dim")
print_row(headers)
print_row(underlines)
for row in data:
print_row(row)
@ -255,33 +147,42 @@ def print_table(headers: List[str], data: List[List[str]]):
def print_list_accounts(accounts):
if accounts:
print_out("Accounts in list</green>:\n")
click.echo("Accounts in list:\n")
print_acct_list(accounts)
else:
print_out("This list has no accounts.")
click.echo("This list has no accounts.")
def print_search_results(results):
accounts = results['accounts']
hashtags = results['hashtags']
accounts = results["accounts"]
hashtags = results["hashtags"]
if accounts:
print_out("\nAccounts:")
click.echo("\nAccounts:")
print_acct_list(accounts)
if hashtags:
print_out("\nHashtags:")
print_out(", ".join([f"<green>#{t['name']}</green>" for t in hashtags]))
click.echo("\nHashtags:")
click.echo(", ".join([format_tag_name(tag) for tag in hashtags]))
if not accounts and not hashtags:
print_out("<yellow>Nothing found</yellow>")
click.echo("Nothing found")
def print_status(status: Status, width: int = 80):
def print_status(status: Status) -> None:
width = get_width()
click.echo(status_to_text(status, width))
def status_to_text(status: Status, width: int) -> str:
return "\n".join(status_lines(status))
def status_lines(status: Status) -> Generator[str, None, None]:
width = get_width()
status_id = status.id
in_reply_to_id = status.in_reply_to_id
reblogged_by = status.account if status.reblog else None
status = status.original
time = status.created_at.strftime('%Y-%m-%d %H:%M %Z')
@ -289,61 +190,60 @@ def print_status(status: Status, width: int = 80):
spacing = width - wcswidth(username) - wcswidth(time) - 2
display_name = status.account.display_name
if display_name:
author = f"{green(display_name)} {blue(username)}"
spacing -= wcswidth(display_name) + 1
else:
author = blue(username)
print_out(
f"<green>{display_name}</green>" if display_name else "",
f"<blue>{username}</blue>",
" " * spacing,
f"<yellow>{time}</yellow>",
)
spaces = " " * spacing
yield f"{author} {spaces} {yellow(time)}"
print_out("")
print_html(status.content, width)
yield ""
yield from html_lines(status.content, width)
if status.media_attachments:
print_out("\nMedia:")
yield ""
yield "Media:"
for attachment in status.media_attachments:
url = attachment.url
for line in wc_wrap(url, width):
print_out(line)
yield line
if status.poll:
print_poll(status.poll)
yield from poll_lines(status.poll)
print_out()
reblogged_by_acct = f"@{reblogged_by.acct}" if reblogged_by else None
yield ""
print_out(
f"ID <yellow>{status_id}</yellow> ",
f"↲ In reply to <yellow>{in_reply_to_id}</yellow> " if in_reply_to_id else "",
f"↻ <blue>@{reblogged_by.acct}</blue> boosted " if reblogged_by else "",
)
reply = f"↲ In reply to {yellow(in_reply_to_id)} " if in_reply_to_id else ""
boost = f"{blue(reblogged_by_acct)} boosted " if reblogged_by else ""
yield f"ID {yellow(status_id)} {reply} {boost}"
def print_html(text, width=80):
def html_lines(html: str, width: int) -> Generator[str, None, None]:
first = True
for paragraph in html_to_paragraphs(text):
for paragraph in html_to_paragraphs(html):
if not first:
print_out("")
yield ""
for line in paragraph:
for subline in wc_wrap(line, width):
print_out(highlight_hashtags(subline))
yield subline
first = False
def print_poll(poll: Poll):
print_out()
def poll_lines(poll: Poll) -> Generator[str, None, None]:
for idx, option in enumerate(poll.options):
perc = (round(100 * option.votes_count / poll.votes_count)
if poll.votes_count and option.votes_count is not None else 0)
if poll.voted and poll.own_votes and idx in poll.own_votes:
voted_for = " <yellow></yellow>"
voted_for = yellow(" ")
else:
voted_for = ""
print_out(f'{option.title} - {perc}% {voted_for}')
yield f"{option.title} - {perc}% {voted_for}"
poll_footer = f'Poll · {poll.votes_count} votes'
@ -354,38 +254,86 @@ def print_poll(poll: Poll):
expires_at = poll.expires_at.strftime("%Y-%m-%d %H:%M")
poll_footer += f" · Closes on {expires_at}"
print_out()
print_out(poll_footer)
yield ""
yield poll_footer
def print_timeline(items: Iterable[Status], width=100):
print_out("" * width)
def print_timeline(items: Iterable[Status]):
print_divider()
for item in items:
print_status(item, width)
print_out("" * width)
print_status(item)
print_divider()
notification_msgs = {
"follow": "{account} now follows you",
"mention": "{account} mentioned you in",
"reblog": "{account} reblogged your status",
"favourite": "{account} favourited your status",
}
def print_notification(notification: Notification, width=100):
account = f"{notification.account.display_name} @{notification.account.acct}"
msg = notification_msgs.get(notification.type)
if msg is None:
return
print_out("" * width)
print_out(msg.format(account=account))
def print_notification(notification: Notification):
print_notification_header(notification)
if notification.status:
print_status(notification.status, width)
print_divider(char="-")
print_status(notification.status)
def print_notifications(notifications: List[Notification], width=100):
def print_notifications(notifications: List[Notification]):
for notification in notifications:
print_divider()
print_notification(notification)
print_out("" * width)
print_divider()
def print_notification_header(notification: Notification):
account_name = format_account_name(notification.account)
if (notification.type == "follow"):
click.echo(f"{account_name} now follows you")
elif (notification.type == "mention"):
click.echo(f"{account_name} mentioned you")
elif (notification.type == "reblog"):
click.echo(f"{account_name} reblogged your status")
elif (notification.type == "favourite"):
click.echo(f"{account_name} favourited your status")
elif (notification.type == "update"):
click.echo(f"{account_name} edited a post")
else:
click.secho(f"Unknown notification type: '{notification.type}'", err=True, fg="yellow")
click.secho("Please report an issue to toot.", err=True, fg="yellow")
def print_divider(char: str = ""):
click.echo(char * get_width())
def format_tag_name(tag):
return green(f"#{tag['name']}")
def format_account_name(account: Account) -> str:
acct = blue(f"@{account.acct}")
if account.display_name:
return f"{green(account.display_name)} {acct}"
else:
return acct
# Shorthand functions for coloring output
def blue(text: Any) -> str:
return click.style(text, fg="blue")
def bold(text: Any) -> str:
return click.style(text, bold=True)
def cyan(text: Any) -> str:
return click.style(text, fg="cyan")
def dim(text: Any) -> str:
return click.style(text, dim=True)
def green(text: Any) -> str:
return click.style(text, fg="green")
def yellow(text: Any) -> str:
return click.style(text, fg="yellow")

View File

@ -1,6 +1,3 @@
import os
import sys
from functools import lru_cache
from os.path import exists, join
from tomlkit import parse
@ -17,7 +14,7 @@ def get_settings_path():
return join(get_config_dir(), TOOT_SETTINGS_FILE_NAME)
def load_settings() -> dict:
def _load_settings() -> dict:
# Used for testing without config file
if DISABLE_SETTINGS:
return {}
@ -33,7 +30,7 @@ def load_settings() -> dict:
@lru_cache(maxsize=None)
def get_settings():
return load_settings()
return _load_settings()
T = TypeVar("T")
@ -62,26 +59,3 @@ def _get_setting(dct, keys, type: Type, default=None):
return _get_setting(dct[key], keys[1:], type, default)
return default
def get_debug() -> bool:
if "--debug" in sys.argv:
return True
return get_setting("common.debug", bool, False)
def get_debug_file() -> Optional[str]:
from_env = os.getenv("TOOT_LOG_FILE")
if from_env:
return from_env
return get_setting("common.debug_file", str)
@lru_cache(maxsize=None)
def get_quiet():
if "--quiet" in sys.argv:
return True
return get_setting("common.quiet", str, False)

View File

@ -3,9 +3,11 @@ import subprocess
import urwid
from concurrent.futures import ThreadPoolExecutor
from typing import NamedTuple, Optional
from toot import api, config, __version__, settings
from toot.console import get_default_visibility
from toot import App, User
from toot.cli import get_default_visibility
from toot.exceptions import ApiError
from .compose import StatusComposer
@ -25,6 +27,12 @@ urwid.set_encoding('UTF-8')
DEFAULT_MAX_TOOT_CHARS = 500
class TuiOptions(NamedTuple):
colors: int
media_viewer: Optional[str]
relative_datetimes: bool
class Header(urwid.WidgetWrap):
def __init__(self, app, user):
self.app = app
@ -80,9 +88,11 @@ class TUI(urwid.Frame):
screen: urwid.BaseScreen
@staticmethod
def create(app, user, args):
def create(app: App, user: User, args: TuiOptions):
"""Factory method, sets up TUI and an event loop."""
screen = TUI.create_screen(args)
screen = urwid.raw_display.Screen()
screen.set_terminal_properties(args.colors)
tui = TUI(app, user, screen, args)
palette = PALETTE.copy()
@ -101,23 +111,11 @@ class TUI(urwid.Frame):
return tui
@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):
def __init__(self, app, user, screen, options: TuiOptions):
self.app = app
self.user = user
self.args = args
self.config = config.load_config()
self.options = options
self.loop = None # late init, set in `create`
self.screen = screen
@ -139,7 +137,6 @@ class TUI(urwid.Frame):
self.can_translate = False
self.account = None
self.followed_accounts = []
self.media_viewer = settings.get_setting("tui.media_viewer", str)
super().__init__(self.body, header=self.header, footer=self.footer)
@ -503,8 +500,15 @@ class TUI(urwid.Frame):
if not urls:
return
if self.media_viewer:
subprocess.run([self.media_viewer] + urls)
media_viewer = self.options.media_viewer
if media_viewer:
try:
subprocess.run([media_viewer] + urls)
except FileNotFoundError:
self.footer.set_error_message(f"Media viewer not found: '{media_viewer}'")
except Exception as ex:
self.exception = ex
self.footer.set_error_message("Failed invoking media viewer. Press X to see exception.")
else:
self.footer.set_error_message("Media viewer not configured")

View File

@ -1,7 +1,7 @@
import urwid
import logging
from toot.console import get_default_visibility
from toot.cli import get_default_visibility
from .constants import VISIBILITY_OPTIONS
from .widgets import Button, EditBox

View File

@ -79,7 +79,7 @@ class Timeline(urwid.Columns):
return urwid.ListBox(walker)
def build_list_item(self, status):
item = StatusListItem(status, self.tui.args.relative_datetimes)
item = StatusListItem(status, self.tui.options.relative_datetimes)
urwid.connect_signal(item, "click", lambda *args:
self.tui.show_context_menu(status))
return urwid.AttrMap(item, None, focus_map={
@ -95,7 +95,7 @@ class Timeline(urwid.Columns):
return None
poll = status.original.data.get("poll")
show_media = status.original.data["media_attachments"] and self.tui.media_viewer
show_media = status.original.data["media_attachments"] and self.tui.options.media_viewer
options = [
"[A]ccount" if not status.is_mine else "",
@ -107,7 +107,6 @@ class Timeline(urwid.Columns):
"[T]hread" if not self.is_thread else "",
"L[i]nks",
"[M]edia" if show_media else "",
self.tui.media_viewer,
"[R]eply",
"[P]oll" if poll and not poll["expired"] else "",
"So[u]rce",

View File

@ -7,7 +7,9 @@ import unicodedata
import warnings
from bs4 import BeautifulSoup
from typing import Dict
from typing import Any, Dict, List
import click
from toot.exceptions import ConsoleError
from urllib.parse import urlparse, urlencode, quote, unquote
@ -38,7 +40,7 @@ def get_text(html):
return unicodedata.normalize("NFKC", text)
def html_to_paragraphs(html):
def html_to_paragraphs(html: str) -> List[List[str]]:
"""Attempt to convert html to plain text while keeping line breaks.
Returns a list of paragraphs, each being a list of lines.
"""
@ -109,7 +111,7 @@ Everything below it will be ignored.
"""
def editor_input(editor: str, initial_text: str):
def editor_input(editor: str, initial_text: str) -> str:
"""Lets user input text using an editor."""
tmp_path = _tmp_status_path()
initial_text = (initial_text or "") + EDITOR_INPUT_INSTRUCTIONS
@ -125,18 +127,7 @@ def editor_input(editor: str, initial_text: str):
return f.read().split(EDITOR_DIVIDER)[0].strip()
def read_char(values, default):
values = [v.lower() for v in values]
while True:
value = input().lower()
if value == "":
return default
if value in values:
return value
def delete_tmp_status_file():
def delete_tmp_status_file() -> None:
try:
os.unlink(_tmp_status_path())
except FileNotFoundError:
@ -148,50 +139,23 @@ def _tmp_status_path() -> str:
return f"{tmp_dir}/.status.toot"
def _use_existing_tmp_file(tmp_path) -> bool:
from toot.output import print_out
def _use_existing_tmp_file(tmp_path: str) -> bool:
if os.path.exists(tmp_path):
print_out(f"<cyan>Found a draft status at: {tmp_path}</cyan>")
print_out("<cyan>[O]pen (default) or [D]elete?</cyan> ", end="")
char = read_char(["o", "d"], "o")
return char == "o"
click.echo(f"Found draft status at: {tmp_path}")
choice = click.Choice(["O", "D"], case_sensitive=False)
char = click.prompt("Open or Delete?", type=choice, default="O")
return char == "O"
return False
def drop_empty_values(data: Dict) -> Dict:
def drop_empty_values(data: Dict[Any, Any]) -> Dict[Any, Any]:
"""Remove keys whose values are null"""
return {k: v for k, v in data.items() if v is not None}
def args_get_instance(instance, scheme, default=None):
if not instance:
return default
if scheme == "http":
_warn_scheme_deprecated()
if instance.startswith("http"):
return instance.rstrip("/")
else:
return f"{scheme}://{instance}"
def _warn_scheme_deprecated():
from toot.output import print_err
print_err("\n".join([
"--disable-https flag is deprecated and will be removed.",
"Please specify the instance as URL instead.",
"e.g. instead of writing:",
" toot instance unsafehost.com --disable-https",
"instead write:",
" toot instance http://unsafehost.com\n"
]))
def urlencode_url(url):
def urlencode_url(url: str) -> str:
parsed_url = urlparse(url)
# unencode before encoding, to prevent double-urlencoding