mirror of
https://github.com/ihabunek/toot.git
synced 2024-11-03 04:17:21 -05:00
commit
3399c8763d
10
.gitignore
vendored
10
.gitignore
vendored
@ -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
|
||||
|
28
CHANGELOG.md
28
CHANGELOG.md
@ -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
|
||||
|
18
Makefile
18
Makefile
@ -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"
|
||||
|
@ -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:
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
19
docs/environment_variables.md
Normal file
19
docs/environment_variables.md
Normal 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
31
docs/shell_completion.md
Normal 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
2
pytest.ini
Normal file
@ -0,0 +1,2 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
@ -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
|
||||
|
8
setup.py
8
setup.py
@ -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',
|
||||
],
|
||||
}
|
||||
)
|
||||
|
@ -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://([^/]+)/([^/]+)/(.+)")
|
||||
|
@ -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 == []
|
||||
|
@ -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")
|
||||
|
@ -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."
|
||||
|
@ -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
|
||||
|
@ -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"]
|
||||
|
@ -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
|
||||
|
163
tests/integration/test_tags.py
Normal file
163
tests/integration/test_tags.py
Normal 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))
|
198
tests/integration/test_timelines.py
Normal file
198
tests/integration/test_timelines.py
Normal 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())
|
149
tests/integration/test_update_account.py
Normal file
149
tests/integration/test_update_account.py
Normal 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
|
@ -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)
|
@ -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'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'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
|
@ -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"
|
@ -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")
|
||||
|
||||
|
||||
|
@ -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'
|
||||
|
||||
|
@ -1,3 +1,3 @@
|
||||
from .console import main
|
||||
from toot.cli import cli
|
||||
|
||||
main()
|
||||
cli()
|
||||
|
142
toot/api.py
142
toot/api.py
@ -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:
|
||||
|
133
toot/auth.py
133
toot/auth.py
@ -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
141
toot/cli/__init__.py
Normal 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
257
toot/cli/accounts.py
Normal 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
158
toot/cli/auth.py
Normal 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
218
toot/cli/lists.py
Normal 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
281
toot/cli/post.py
Normal 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
114
toot/cli/read.py
Normal 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
148
toot/cli/statuses.py
Normal 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
163
toot/cli/tags.py
Normal 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
180
toot/cli/timelines.py
Normal 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
44
toot/cli/tui.py
Normal 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
75
toot/cli/validators.py
Normal 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)}")
|
669
toot/commands.py
669
toot/commands.py
@ -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()
|
@ -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])
|
||||
|
966
toot/console.py
966
toot/console.py
@ -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
|
@ -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")
|
||||
|
||||
|
@ -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."""
|
||||
|
428
toot/output.py
428
toot/output.py
@ -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")
|
||||
|
@ -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)
|
||||
|
@ -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")
|
||||
|
||||
|
@ -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
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user