mirror of
https://github.com/ihabunek/toot.git
synced 2024-09-29 04:35:54 -04:00
Merge branch 'master' into images
This commit is contained in:
commit
c3e715b46c
10
.gitignore
vendored
10
.gitignore
vendored
@ -6,12 +6,14 @@
|
|||||||
/.env
|
/.env
|
||||||
/.envrc
|
/.envrc
|
||||||
/.pytest_cache/
|
/.pytest_cache/
|
||||||
|
/book
|
||||||
/build/
|
/build/
|
||||||
|
/bundle/
|
||||||
/dist/
|
/dist/
|
||||||
/htmlcov/
|
/htmlcov/
|
||||||
/tmp/
|
|
||||||
/toot-*.tar.gz
|
|
||||||
debug.log
|
|
||||||
/pyrightconfig.json
|
/pyrightconfig.json
|
||||||
|
/tmp/
|
||||||
|
/toot-*.pyz
|
||||||
|
/toot-*.tar.gz
|
||||||
/venv/
|
/venv/
|
||||||
/book
|
debug.log
|
||||||
|
29
CHANGELOG.md
29
CHANGELOG.md
@ -3,6 +3,35 @@ Changelog
|
|||||||
|
|
||||||
<!-- Do not edit. This file is automatically generated from changelog.yaml.-->
|
<!-- 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 `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 `--json` option to tags commands
|
||||||
|
* Add `--json` option to lists 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)**
|
**0.39.0 (2023-11-23)**
|
||||||
|
|
||||||
* Add `--json` option to many commands, this makes them print the JSON data
|
* Add `--json` option to many commands, this makes them print the JSON data
|
||||||
|
19
Makefile
19
Makefile
@ -15,12 +15,12 @@ test:
|
|||||||
coverage:
|
coverage:
|
||||||
coverage erase
|
coverage erase
|
||||||
coverage run
|
coverage run
|
||||||
coverage html
|
coverage html --omit "toot/tui/*"
|
||||||
coverage report
|
coverage report
|
||||||
|
|
||||||
clean :
|
clean :
|
||||||
find . -name "*pyc" | xargs rm -rf $1
|
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:
|
changelog:
|
||||||
./scripts/generate_changelog > CHANGELOG.md
|
./scripts/generate_changelog > CHANGELOG.md
|
||||||
@ -30,7 +30,20 @@ docs: changelog
|
|||||||
mdbook build
|
mdbook build
|
||||||
|
|
||||||
docs-serve:
|
docs-serve:
|
||||||
mdbook serve
|
mdbook serve --port 8000
|
||||||
|
|
||||||
docs-deploy: docs
|
docs-deploy: docs
|
||||||
rsync --archive --compress --delete --stats book/ bezdomni:web/toot
|
rsync --archive --compress --delete --stats book/ bezdomni:web/toot
|
||||||
|
|
||||||
|
.PHONY: bundle
|
||||||
|
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,24 @@
|
|||||||
|
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 `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 `--json` option to tags commands"
|
||||||
|
- "Add `--json` option to lists 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:
|
0.39.0:
|
||||||
date: 2023-11-23
|
date: 2023-11-23
|
||||||
changes:
|
changes:
|
||||||
|
@ -6,6 +6,8 @@
|
|||||||
- [Usage](usage.md)
|
- [Usage](usage.md)
|
||||||
- [Advanced](advanced.md)
|
- [Advanced](advanced.md)
|
||||||
- [Settings](settings.md)
|
- [Settings](settings.md)
|
||||||
|
- [Shell completion](shell_completion.md)
|
||||||
|
- [Environment variables](environment_variables.md)
|
||||||
- [TUI](tui.md)
|
- [TUI](tui.md)
|
||||||
- [Contributing](contributing.md)
|
- [Contributing](contributing.md)
|
||||||
- [Documentation](documentation.md)
|
- [Documentation](documentation.md)
|
||||||
|
@ -3,6 +3,35 @@ Changelog
|
|||||||
|
|
||||||
<!-- Do not edit. This file is automatically generated from changelog.yaml.-->
|
<!-- 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 `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 `--json` option to tags commands
|
||||||
|
* Add `--json` option to lists 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)**
|
**0.39.0 (2023-11-23)**
|
||||||
|
|
||||||
* Add `--json` option to many commands, this makes them print the JSON data
|
* 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"]
|
changes = data[version]["changes"]
|
||||||
print(f"**{version} ({date})**")
|
print(f"**{version} ({date})**")
|
||||||
print()
|
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:
|
for c in changes:
|
||||||
lines = textwrap.wrap(c, 78)
|
lines = textwrap.wrap(c, 78)
|
||||||
initial = True
|
initial = True
|
||||||
|
8
setup.py
8
setup.py
@ -12,7 +12,7 @@ and blocking accounts and other actions.
|
|||||||
|
|
||||||
setup(
|
setup(
|
||||||
name='toot',
|
name='toot',
|
||||||
version='0.39.0',
|
version='0.40.0',
|
||||||
description='Mastodon CLI client',
|
description='Mastodon CLI client',
|
||||||
long_description=long_description.strip(),
|
long_description=long_description.strip(),
|
||||||
author='Ivan Habunek',
|
author='Ivan Habunek',
|
||||||
@ -31,9 +31,10 @@ setup(
|
|||||||
'License :: OSI Approved :: GNU General Public License v3 (GPLv3)',
|
'License :: OSI Approved :: GNU General Public License v3 (GPLv3)',
|
||||||
'Programming Language :: Python :: 3',
|
'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",
|
python_requires=">=3.7",
|
||||||
install_requires=[
|
install_requires=[
|
||||||
|
"click~=8.1",
|
||||||
"requests>=2.13,<3.0",
|
"requests>=2.13,<3.0",
|
||||||
"beautifulsoup4>=4.5.0,<5.0",
|
"beautifulsoup4>=4.5.0,<5.0",
|
||||||
"wcwidth>=0.1.7",
|
"wcwidth>=0.1.7",
|
||||||
@ -60,11 +61,12 @@ setup(
|
|||||||
"pytest-xdist[psutil]",
|
"pytest-xdist[psutil]",
|
||||||
"setuptools",
|
"setuptools",
|
||||||
"vermin",
|
"vermin",
|
||||||
|
"typing-extensions",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
entry_points={
|
entry_points={
|
||||||
'console_scripts': [
|
'console_scripts': [
|
||||||
'toot=toot.console:main',
|
'toot=toot.cli:cli',
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -20,11 +20,10 @@ import psycopg2
|
|||||||
import pytest
|
import pytest
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
from click.testing import CliRunner, Result
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from toot import api, App, User
|
from toot import api, App, User
|
||||||
from toot.console import run_command
|
from toot.cli import Context, TootObj
|
||||||
from toot.exceptions import ApiError, ConsoleError
|
|
||||||
from toot.output import print_out
|
|
||||||
|
|
||||||
|
|
||||||
def pytest_configure(config):
|
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
|
# Mastodon database name, used to confirm user registration without having to click the link
|
||||||
DATABASE_DSN = os.getenv("TOOT_TEST_DATABASE_DSN")
|
DATABASE_DSN = os.getenv("TOOT_TEST_DATABASE_DSN")
|
||||||
|
TOOT_TEST_BASE_URL = os.getenv("TOOT_TEST_BASE_URL")
|
||||||
|
|
||||||
# Toot logo used for testing image upload
|
# Toot logo used for testing image upload
|
||||||
TRUMPET = str(Path(__file__).parent.parent.parent / "trumpet.png")
|
TRUMPET = str(Path(__file__).parent.parent.parent / "trumpet.png")
|
||||||
@ -72,12 +72,10 @@ def confirm_user(email):
|
|||||||
# DO NOT USE PUBLIC INSTANCES!!!
|
# DO NOT USE PUBLIC INSTANCES!!!
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def base_url():
|
def base_url():
|
||||||
base_url = os.getenv("TOOT_TEST_BASE_URL")
|
if not TOOT_TEST_BASE_URL:
|
||||||
|
|
||||||
if not base_url:
|
|
||||||
pytest.skip("Skipping integration tests, TOOT_TEST_BASE_URL not set")
|
pytest.skip("Skipping integration tests, TOOT_TEST_BASE_URL not set")
|
||||||
|
|
||||||
return base_url
|
return TOOT_TEST_BASE_URL
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
@ -105,37 +103,47 @@ def friend_id(app, user, friend):
|
|||||||
return api.find_account(app, user, friend.username)["id"]
|
return api.find_account(app, user, friend.username)["id"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
def run(app, user, capsys):
|
def testing_env():
|
||||||
def _run(command, *params, as_user=None):
|
os.environ["TOOT_TESTING"] = "true"
|
||||||
# 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))
|
|
||||||
|
|
||||||
out, err = capsys.readouterr()
|
|
||||||
assert err == ""
|
@pytest.fixture(scope="session")
|
||||||
return strip_ansi(out)
|
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
|
return _run
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@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):
|
def _run_json(command, *params):
|
||||||
out = run(command, *params)
|
obj = TootObj(test_ctx=Context(app, user))
|
||||||
return json.loads(out)
|
result = runner.invoke(command, params, obj=obj)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
return json.loads(result.stdout)
|
||||||
return _run_json
|
return _run_json
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def run_anon(capsys):
|
def run_anon(runner):
|
||||||
def _run(command, *params):
|
def _run(command, *params) -> Result:
|
||||||
run_command(None, None, command, params or [])
|
obj = TootObj(test_ctx=Context(None, None))
|
||||||
out, err = capsys.readouterr()
|
return runner.invoke(command, params, obj=obj)
|
||||||
assert err == ""
|
|
||||||
return strip_ansi(out)
|
|
||||||
return _run
|
return _run
|
||||||
|
|
||||||
|
|
||||||
@ -143,12 +151,6 @@ def run_anon(capsys):
|
|||||||
# Utils
|
# 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):
|
def posted_status_id(out):
|
||||||
pattern = re.compile(r"Toot posted: http://([^/]+)/([^/]+)/(.+)")
|
pattern = re.compile(r"Toot posted: http://([^/]+)/([^/]+)/(.+)")
|
||||||
|
@ -1,18 +1,23 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from toot import App, User, api
|
from toot import App, User, api, cli
|
||||||
from toot.entities import Account, Relationship, from_dict
|
from toot.entities import Account, Relationship, from_dict
|
||||||
|
|
||||||
|
|
||||||
def test_whoami(user: User, run):
|
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
|
# TODO: test other fields once updating account is supported
|
||||||
|
out = result.stdout.strip()
|
||||||
assert f"@{user.username}" in out
|
assert f"@{user.username}" in out
|
||||||
|
|
||||||
|
|
||||||
def test_whoami_json(user: User, run):
|
def test_whoami_json(user: User, run):
|
||||||
out = run("whoami", "--json")
|
result = run(cli.read.whoami, "--json")
|
||||||
account = from_dict(Account, json.loads(out))
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
account = from_dict(Account, json.loads(result.stdout))
|
||||||
assert account.username == user.username
|
assert account.username == user.username
|
||||||
|
|
||||||
|
|
||||||
@ -25,83 +30,95 @@ def test_whois(app: App, friend: User, run):
|
|||||||
]
|
]
|
||||||
|
|
||||||
for username in variants:
|
for username in variants:
|
||||||
out = run("whois", username)
|
result = run(cli.read.whois, username)
|
||||||
assert f"@{friend.username}" in out
|
assert result.exit_code == 0
|
||||||
|
assert f"@{friend.username}" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
def test_following(app: App, user: User, friend: User, friend_id, run):
|
def test_following(app: App, user: User, friend: User, friend_id, run):
|
||||||
# Make sure we're not initally following friend
|
# Make sure we're not initally following friend
|
||||||
api.unfollow(app, user, friend_id)
|
api.unfollow(app, user, friend_id)
|
||||||
|
|
||||||
out = run("following", user.username)
|
result = run(cli.accounts.following, user.username)
|
||||||
assert out == ""
|
assert result.exit_code == 0
|
||||||
|
assert result.stdout.strip() == ""
|
||||||
|
|
||||||
out = run("follow", friend.username)
|
result = run(cli.accounts.follow, friend.username)
|
||||||
assert out == f"✓ You are now following {friend.username}"
|
assert result.exit_code == 0
|
||||||
|
assert result.stdout.strip() == f"✓ You are now following {friend.username}"
|
||||||
|
|
||||||
out = run("following", user.username)
|
result = run(cli.accounts.following, user.username)
|
||||||
assert friend.username in out
|
assert result.exit_code == 0
|
||||||
|
assert friend.username in result.stdout.strip()
|
||||||
|
|
||||||
# If no account is given defaults to logged in user
|
# If no account is given defaults to logged in user
|
||||||
out = run("following")
|
result = run(cli.accounts.following)
|
||||||
assert friend.username in out
|
assert result.exit_code == 0
|
||||||
|
assert friend.username in result.stdout.strip()
|
||||||
|
|
||||||
out = run("unfollow", friend.username)
|
result = run(cli.accounts.unfollow, friend.username)
|
||||||
assert out == f"✓ You are no longer following {friend.username}"
|
assert result.exit_code == 0
|
||||||
|
assert result.stdout.strip() == f"✓ You are no longer following {friend.username}"
|
||||||
|
|
||||||
out = run("following", user.username)
|
result = run(cli.accounts.following, user.username)
|
||||||
assert out == ""
|
assert result.exit_code == 0
|
||||||
|
assert result.stdout.strip() == ""
|
||||||
|
|
||||||
|
|
||||||
def test_following_case_insensitive(user: User, friend: User, run):
|
def test_following_case_insensitive(user: User, friend: User, run):
|
||||||
assert friend.username != friend.username.upper()
|
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()}"
|
assert out == f"✓ You are now following {friend.username.upper()}"
|
||||||
|
|
||||||
|
|
||||||
def test_following_not_found(run):
|
def test_following_not_found(run):
|
||||||
out = run("follow", "bananaman")
|
result = run(cli.accounts.follow, "bananaman")
|
||||||
assert out == "Account not found"
|
assert result.exit_code == 1
|
||||||
|
assert result.stderr.strip() == "Error: Account not found"
|
||||||
|
|
||||||
out = run("unfollow", "bananaman")
|
result = run(cli.accounts.unfollow, "bananaman")
|
||||||
assert out == "Account not found"
|
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):
|
def test_following_json(app: App, user: User, friend: User, user_id, friend_id, run_json):
|
||||||
# Make sure we're not initally following friend
|
# Make sure we're not initally following friend
|
||||||
api.unfollow(app, user, friend_id)
|
api.unfollow(app, user, friend_id)
|
||||||
|
|
||||||
result = run_json("following", user.username, "--json")
|
result = run_json(cli.accounts.following, user.username, "--json")
|
||||||
assert result == []
|
assert result == []
|
||||||
|
|
||||||
result = run_json("followers", friend.username, "--json")
|
result = run_json(cli.accounts.followers, friend.username, "--json")
|
||||||
assert result == []
|
assert result == []
|
||||||
|
|
||||||
result = run_json("follow", friend.username, "--json")
|
result = run_json(cli.accounts.follow, friend.username, "--json")
|
||||||
relationship = from_dict(Relationship, result)
|
relationship = from_dict(Relationship, result)
|
||||||
assert relationship.id == friend_id
|
assert relationship.id == friend_id
|
||||||
assert relationship.following is True
|
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)
|
relationship = from_dict(Relationship, result)
|
||||||
assert relationship.id == friend_id
|
assert relationship.id == friend_id
|
||||||
|
|
||||||
# If no account is given defaults to logged in user
|
# 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)
|
relationship = from_dict(Relationship, result)
|
||||||
assert relationship.id == friend_id
|
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
|
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["id"] == friend_id
|
||||||
assert result["following"] is False
|
assert result["following"] is False
|
||||||
|
|
||||||
result = run_json("following", user.username, "--json")
|
result = run_json(cli.accounts.following, user.username, "--json")
|
||||||
assert result == []
|
assert result == []
|
||||||
|
|
||||||
result = run_json("followers", friend.username, "--json")
|
result = run_json(cli.accounts.followers, friend.username, "--json")
|
||||||
assert result == []
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
@ -109,57 +126,77 @@ def test_mute(app, user, friend, friend_id, run):
|
|||||||
# Make sure we're not initially muting friend
|
# Make sure we're not initially muting friend
|
||||||
api.unmute(app, user, friend_id)
|
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"
|
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}"
|
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
|
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"
|
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"
|
assert out == "No accounts muted"
|
||||||
|
|
||||||
|
|
||||||
def test_mute_case_insensitive(friend: User, run):
|
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()}"
|
assert out == f"✓ You have muted {friend.username.upper()}"
|
||||||
|
|
||||||
|
|
||||||
def test_mute_not_found(run):
|
def test_mute_not_found(run):
|
||||||
out = run("mute", "doesnotexistperson")
|
result = run(cli.accounts.mute, "doesnotexistperson")
|
||||||
assert out == f"Account not found"
|
assert result.exit_code == 1
|
||||||
|
assert result.stderr.strip() == "Error: Account not found"
|
||||||
|
|
||||||
out = run("unmute", "doesnotexistperson")
|
result = run(cli.accounts.unmute, "doesnotexistperson")
|
||||||
assert out == f"Account not found"
|
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):
|
def test_mute_json(app: App, user: User, friend: User, run_json, friend_id):
|
||||||
# Make sure we're not initially muting friend
|
# Make sure we're not initially muting friend
|
||||||
api.unmute(app, user, friend_id)
|
api.unmute(app, user, friend_id)
|
||||||
|
|
||||||
result = run_json("muted", "--json")
|
result = run_json(cli.accounts.muted, "--json")
|
||||||
assert result == []
|
assert result == []
|
||||||
|
|
||||||
result = run_json("mute", friend.username, "--json")
|
result = run_json(cli.accounts.mute, friend.username, "--json")
|
||||||
relationship = from_dict(Relationship, result)
|
relationship = from_dict(Relationship, result)
|
||||||
assert relationship.id == friend_id
|
assert relationship.id == friend_id
|
||||||
assert relationship.muting is True
|
assert relationship.muting is True
|
||||||
|
|
||||||
[result] = run_json("muted", "--json")
|
[result] = run_json(cli.accounts.muted, "--json")
|
||||||
account = from_dict(Account, result)
|
account = from_dict(Account, result)
|
||||||
assert account.id == friend_id
|
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)
|
relationship = from_dict(Relationship, result)
|
||||||
assert relationship.id == friend_id
|
assert relationship.id == friend_id
|
||||||
assert relationship.muting is False
|
assert relationship.muting is False
|
||||||
|
|
||||||
result = run_json("muted", "--json")
|
result = run_json(cli.accounts.muted, "--json")
|
||||||
assert result == []
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
@ -167,52 +204,71 @@ def test_block(app, user, friend, friend_id, run):
|
|||||||
# Make sure we're not initially blocking friend
|
# Make sure we're not initially blocking friend
|
||||||
api.unblock(app, user, friend_id)
|
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"
|
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}"
|
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
|
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"
|
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"
|
assert out == "No accounts blocked"
|
||||||
|
|
||||||
|
|
||||||
def test_block_case_insensitive(friend: User, run):
|
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()}"
|
assert out == f"✓ You are now blocking {friend.username.upper()}"
|
||||||
|
|
||||||
|
|
||||||
def test_block_not_found(run):
|
def test_block_not_found(run):
|
||||||
out = run("block", "doesnotexistperson")
|
result = run(cli.accounts.block, "doesnotexistperson")
|
||||||
assert out == f"Account not found"
|
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):
|
def test_block_json(app: App, user: User, friend: User, run_json, friend_id):
|
||||||
# Make sure we're not initially blocking friend
|
# Make sure we're not initially blocking friend
|
||||||
api.unblock(app, user, friend_id)
|
api.unblock(app, user, friend_id)
|
||||||
|
|
||||||
result = run_json("blocked", "--json")
|
result = run_json(cli.accounts.blocked, "--json")
|
||||||
assert result == []
|
assert result == []
|
||||||
|
|
||||||
result = run_json("block", friend.username, "--json")
|
result = run_json(cli.accounts.block, friend.username, "--json")
|
||||||
relationship = from_dict(Relationship, result)
|
relationship = from_dict(Relationship, result)
|
||||||
assert relationship.id == friend_id
|
assert relationship.id == friend_id
|
||||||
assert relationship.blocking is True
|
assert relationship.blocking is True
|
||||||
|
|
||||||
[result] = run_json("blocked", "--json")
|
[result] = run_json(cli.accounts.blocked, "--json")
|
||||||
account = from_dict(Account, result)
|
account = from_dict(Account, result)
|
||||||
assert account.id == friend_id
|
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)
|
relationship = from_dict(Relationship, result)
|
||||||
assert relationship.id == friend_id
|
assert relationship.id == friend_id
|
||||||
assert relationship.blocking is False
|
assert relationship.blocking is False
|
||||||
|
|
||||||
result = run_json("blocked", "--json")
|
result = run_json(cli.accounts.blocked, "--json")
|
||||||
assert result == []
|
assert result == []
|
||||||
|
@ -1,129 +1,217 @@
|
|||||||
from tests.integration.conftest import TRUMPET
|
from typing import Any, Dict
|
||||||
from toot import api
|
from unittest import mock
|
||||||
from toot.entities import Account, from_dict
|
from unittest.mock import MagicMock
|
||||||
from toot.utils import get_text
|
|
||||||
|
from toot import User, cli
|
||||||
|
from toot.cli import Run
|
||||||
|
|
||||||
|
# TODO: figure out how to test login
|
||||||
|
|
||||||
|
|
||||||
def test_update_account_no_options(run):
|
EMPTY_CONFIG: Dict[Any, Any] = {
|
||||||
out = run("update_account")
|
"apps": {},
|
||||||
assert out == "Please specify at least one option to update the account"
|
"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):
|
def test_env(run: Run):
|
||||||
out = run("update_account", "--display-name", "elwood")
|
result = run(cli.auth.env)
|
||||||
assert out == "✓ Account updated"
|
assert result.exit_code == 0
|
||||||
|
assert "toot" in result.stdout
|
||||||
account = api.verify_credentials(app, user).json()
|
assert "Python" in result.stdout
|
||||||
assert account["display_name"] == "elwood"
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_account_json(run_json, app, user):
|
@mock.patch("toot.config.load_config")
|
||||||
out = run_json("update_account", "--display-name", "elwood", "--json")
|
def test_auth_empty(load_config: MagicMock, run: Run):
|
||||||
account = from_dict(Account, out)
|
load_config.return_value = EMPTY_CONFIG
|
||||||
assert account.acct == user.username
|
result = run(cli.auth.auth)
|
||||||
assert account.display_name == "elwood"
|
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):
|
@mock.patch("toot.config.load_config")
|
||||||
note = ("It's 106 miles to Chicago, we got a full tank of gas, half a pack "
|
def test_auth_full(load_config: MagicMock, run: Run):
|
||||||
"of cigarettes, it's dark... and we're wearing sunglasses.")
|
load_config.return_value = SAMPLE_CONFIG
|
||||||
|
result = run(cli.auth.auth)
|
||||||
out = run("update_account", "--note", note)
|
assert result.exit_code == 0
|
||||||
assert out == "✓ Account updated"
|
assert result.stdout.strip().startswith("Authenticated accounts:")
|
||||||
|
assert "frank@foo.social" in result.stdout
|
||||||
account = api.verify_credentials(app, user).json()
|
assert "frank@bar.social" in result.stdout
|
||||||
assert get_text(account["note"]) == note
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_account_language(run, app, user):
|
# Saving config is mocked so we don't mess up our local config
|
||||||
out = run("update_account", "--language", "hr")
|
# TODO: could this be implemented using an auto-use fixture so we have it always
|
||||||
assert out == "✓ Account updated"
|
# 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()
|
result = run(
|
||||||
assert account["source"]["language"] == "hr"
|
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):
|
@mock.patch("toot.config.load_app")
|
||||||
out = run("update_account", "--privacy", "private")
|
@mock.patch("toot.config.save_app")
|
||||||
assert out == "✓ Account updated"
|
@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()
|
result = run(
|
||||||
assert account["source"]["privacy"] == "private"
|
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):
|
@mock.patch("toot.config.load_config")
|
||||||
account = api.verify_credentials(app, user).json()
|
@mock.patch("toot.config.delete_user")
|
||||||
old_value = account["avatar"]
|
def test_logout(delete_user: MagicMock, load_config: MagicMock, run: Run):
|
||||||
|
load_config.return_value = SAMPLE_CONFIG
|
||||||
|
|
||||||
out = run("update_account", "--avatar", TRUMPET)
|
result = run(cli.auth.logout, "frank@foo.social")
|
||||||
assert out == "✓ Account updated"
|
assert result.exit_code == 0
|
||||||
|
assert result.stdout.strip() == "✓ Account frank@foo.social logged out"
|
||||||
account = api.verify_credentials(app, user).json()
|
delete_user.assert_called_once_with(User("foo.social", "frank", "123"))
|
||||||
assert account["avatar"] != old_value
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_account_header(run, app, user):
|
@mock.patch("toot.config.load_config")
|
||||||
account = api.verify_credentials(app, user).json()
|
def test_logout_not_logged_in(load_config: MagicMock, run: Run):
|
||||||
old_value = account["header"]
|
load_config.return_value = EMPTY_CONFIG
|
||||||
|
|
||||||
out = run("update_account", "--header", TRUMPET)
|
result = run(cli.auth.logout)
|
||||||
assert out == "✓ Account updated"
|
assert result.exit_code == 1
|
||||||
|
assert result.stderr.strip() == "Error: You're not logged into any accounts"
|
||||||
account = api.verify_credentials(app, user).json()
|
|
||||||
assert account["header"] != old_value
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_account_locked(run, app, user):
|
@mock.patch("toot.config.load_config")
|
||||||
out = run("update_account", "--locked")
|
def test_logout_account_not_specified(load_config: MagicMock, run: Run):
|
||||||
assert out == "✓ Account updated"
|
load_config.return_value = SAMPLE_CONFIG
|
||||||
|
|
||||||
account = api.verify_credentials(app, user).json()
|
result = run(cli.auth.logout)
|
||||||
assert account["locked"] is True
|
assert result.exit_code == 1
|
||||||
|
assert result.stderr.startswith("Error: Specify account to log out")
|
||||||
out = run("update_account", "--no-locked")
|
|
||||||
assert out == "✓ Account updated"
|
|
||||||
|
|
||||||
account = api.verify_credentials(app, user).json()
|
|
||||||
assert account["locked"] is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_account_bot(run, app, user):
|
@mock.patch("toot.config.load_config")
|
||||||
out = run("update_account", "--bot")
|
def test_logout_account_does_not_exist(load_config: MagicMock, run: Run):
|
||||||
assert out == "✓ Account updated"
|
load_config.return_value = SAMPLE_CONFIG
|
||||||
|
|
||||||
account = api.verify_credentials(app, user).json()
|
result = run(cli.auth.logout, "banana")
|
||||||
assert account["bot"] is True
|
assert result.exit_code == 1
|
||||||
|
assert result.stderr.startswith("Error: Account not found")
|
||||||
out = run("update_account", "--no-bot")
|
|
||||||
assert out == "✓ Account updated"
|
|
||||||
|
|
||||||
account = api.verify_credentials(app, user).json()
|
|
||||||
assert account["bot"] is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_account_discoverable(run, app, user):
|
@mock.patch("toot.config.load_config")
|
||||||
out = run("update_account", "--discoverable")
|
@mock.patch("toot.config.activate_user")
|
||||||
assert out == "✓ Account updated"
|
def test_activate(activate_user: MagicMock, load_config: MagicMock, run: Run):
|
||||||
|
load_config.return_value = SAMPLE_CONFIG
|
||||||
|
|
||||||
account = api.verify_credentials(app, user).json()
|
result = run(cli.auth.activate, "frank@foo.social")
|
||||||
assert account["discoverable"] is True
|
assert result.exit_code == 0
|
||||||
|
assert result.stdout.strip() == "✓ Account frank@foo.social activated"
|
||||||
out = run("update_account", "--no-discoverable")
|
activate_user.assert_called_once_with(User("foo.social", "frank", "123"))
|
||||||
assert out == "✓ Account updated"
|
|
||||||
|
|
||||||
account = api.verify_credentials(app, user).json()
|
|
||||||
assert account["discoverable"] is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_account_sensitive(run, app, user):
|
@mock.patch("toot.config.load_config")
|
||||||
out = run("update_account", "--sensitive")
|
def test_activate_not_logged_in(load_config: MagicMock, run: Run):
|
||||||
assert out == "✓ Account updated"
|
load_config.return_value = EMPTY_CONFIG
|
||||||
|
|
||||||
account = api.verify_credentials(app, user).json()
|
result = run(cli.auth.activate)
|
||||||
assert account["source"]["sensitive"] is True
|
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()
|
@mock.patch("toot.config.load_config")
|
||||||
assert account["source"]["sensitive"] is False
|
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,162 @@
|
|||||||
|
from uuid import uuid4
|
||||||
|
from toot import cli
|
||||||
|
|
||||||
from tests.integration.conftest import register_account
|
from tests.integration.conftest import register_account
|
||||||
|
|
||||||
|
|
||||||
def test_lists_empty(run):
|
def test_lists_empty(run):
|
||||||
out = run("lists")
|
result = run(cli.lists.list)
|
||||||
assert out == "You have no lists defined."
|
assert result.exit_code == 0
|
||||||
|
assert result.stdout.strip() == "You have no lists defined."
|
||||||
|
|
||||||
|
|
||||||
|
def test_lists_empty_json(run_json):
|
||||||
|
lists = run_json(cli.lists.list, "--json")
|
||||||
|
assert lists == []
|
||||||
|
|
||||||
|
|
||||||
def test_list_create_delete(run):
|
def test_list_create_delete(run):
|
||||||
out = run("list_create", "banana")
|
result = run(cli.lists.create, "banana")
|
||||||
assert out == '✓ List "banana" created.'
|
assert result.exit_code == 0
|
||||||
|
assert result.stdout.strip() == '✓ List "banana" created.'
|
||||||
|
|
||||||
out = run("lists")
|
result = run(cli.lists.list)
|
||||||
assert "banana" in out
|
assert result.exit_code == 0
|
||||||
|
assert "banana" in result.stdout
|
||||||
|
|
||||||
out = run("list_create", "mango")
|
result = run(cli.lists.create, "mango")
|
||||||
assert out == '✓ List "mango" created.'
|
assert result.exit_code == 0
|
||||||
|
assert result.stdout.strip() == '✓ List "mango" created.'
|
||||||
|
|
||||||
out = run("lists")
|
result = run(cli.lists.list)
|
||||||
assert "banana" in out
|
assert result.exit_code == 0
|
||||||
assert "mango" in out
|
assert "banana" in result.stdout
|
||||||
|
assert "mango" in result.stdout
|
||||||
|
|
||||||
out = run("list_delete", "banana")
|
result = run(cli.lists.delete, "banana")
|
||||||
assert out == '✓ List "banana" deleted.'
|
assert result.exit_code == 0
|
||||||
|
assert result.stdout.strip() == '✓ List "banana" deleted.'
|
||||||
|
|
||||||
out = run("lists")
|
result = run(cli.lists.list)
|
||||||
assert "banana" not in out
|
assert result.exit_code == 0
|
||||||
assert "mango" in out
|
assert "banana" not in result.stdout
|
||||||
|
assert "mango" in result.stdout
|
||||||
|
|
||||||
out = run("list_delete", "mango")
|
result = run(cli.lists.delete, "mango")
|
||||||
assert out == '✓ List "mango" deleted.'
|
assert result.exit_code == 0
|
||||||
|
assert result.stdout.strip() == '✓ List "mango" deleted.'
|
||||||
|
|
||||||
out = run("lists")
|
result = run(cli.lists.list)
|
||||||
assert out == "You have no lists defined."
|
assert result.exit_code == 0
|
||||||
|
assert result.stdout.strip() == "You have no lists defined."
|
||||||
|
|
||||||
out = run("list_delete", "mango")
|
result = run(cli.lists.delete, "mango")
|
||||||
assert out == "List not found"
|
assert result.exit_code == 1
|
||||||
|
assert result.stderr.strip() == "Error: List not found"
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_create_delete_json(run, run_json):
|
||||||
|
result = run_json(cli.lists.list, "--json")
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
list = run_json(cli.lists.create, "banana", "--json")
|
||||||
|
assert list["title"] == "banana"
|
||||||
|
|
||||||
|
[list] = run_json(cli.lists.list, "--json")
|
||||||
|
assert list["title"] == "banana"
|
||||||
|
|
||||||
|
list = run_json(cli.lists.create, "mango", "--json")
|
||||||
|
assert list["title"] == "mango"
|
||||||
|
|
||||||
|
lists = run_json(cli.lists.list, "--json")
|
||||||
|
[list1, list2] = sorted(lists, key=lambda l: l["title"])
|
||||||
|
assert list1["title"] == "banana"
|
||||||
|
assert list2["title"] == "mango"
|
||||||
|
|
||||||
|
result = run_json(cli.lists.delete, "banana", "--json")
|
||||||
|
assert result == {}
|
||||||
|
|
||||||
|
[list] = run_json(cli.lists.list, "--json")
|
||||||
|
assert list["title"] == "mango"
|
||||||
|
|
||||||
|
result = run_json(cli.lists.delete, "mango", "--json")
|
||||||
|
assert result == {}
|
||||||
|
|
||||||
|
result = run_json(cli.lists.list, "--json")
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
result = run(cli.lists.delete, "mango", "--json")
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert result.stderr.strip() == "Error: List not found"
|
||||||
|
|
||||||
|
|
||||||
def test_list_add_remove(run, app):
|
def test_list_add_remove(run, app):
|
||||||
|
list_name = str(uuid4())
|
||||||
acc = register_account(app)
|
acc = register_account(app)
|
||||||
run("list_create", "foo")
|
run(cli.lists.create, list_name)
|
||||||
|
|
||||||
out = run("list_add", "foo", acc.username)
|
result = run(cli.lists.add, list_name, acc.username)
|
||||||
assert out == f"You must follow @{acc.username} before adding this account to a list."
|
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)
|
result = run(cli.lists.add, list_name, acc.username)
|
||||||
assert out == f'✓ Added account "{acc.username}"'
|
assert result.exit_code == 0
|
||||||
|
assert result.stdout.strip() == f'✓ Added account "{acc.username}"'
|
||||||
|
|
||||||
out = run("list_accounts", "foo")
|
result = run(cli.lists.accounts, list_name)
|
||||||
assert acc.username in out
|
assert result.exit_code == 0
|
||||||
|
assert acc.username in result.stdout
|
||||||
|
|
||||||
# Account doesn't exist
|
# Account doesn't exist
|
||||||
out = run("list_add", "foo", "does_not_exist")
|
result = run(cli.lists.add, list_name, "does_not_exist")
|
||||||
assert out == "Account not found"
|
assert result.exit_code == 1
|
||||||
|
assert result.stderr.strip() == "Error: Account not found"
|
||||||
|
|
||||||
# List doesn't exist
|
# List doesn't exist
|
||||||
out = run("list_add", "does_not_exist", acc.username)
|
result = run(cli.lists.add, "does_not_exist", acc.username)
|
||||||
assert out == "List not found"
|
assert result.exit_code == 1
|
||||||
|
assert result.stderr.strip() == "Error: List not found"
|
||||||
|
|
||||||
out = run("list_remove", "foo", acc.username)
|
result = run(cli.lists.remove, list_name, acc.username)
|
||||||
assert out == f'✓ Removed account "{acc.username}"'
|
assert result.exit_code == 0
|
||||||
|
assert result.stdout.strip() == f'✓ Removed account "{acc.username}"'
|
||||||
|
|
||||||
out = run("list_accounts", "foo")
|
result = run(cli.lists.accounts, list_name)
|
||||||
assert out == "This list has no accounts."
|
assert result.exit_code == 0
|
||||||
|
assert result.stdout.strip() == "This list has no accounts."
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_add_remove_json(run, run_json, app):
|
||||||
|
list_name = str(uuid4())
|
||||||
|
acc = register_account(app)
|
||||||
|
run(cli.lists.create, list_name)
|
||||||
|
|
||||||
|
result = run(cli.lists.add, list_name, acc.username, "--json")
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert result.stderr.strip() == f"Error: You must follow @{acc.username} before adding this account to a list."
|
||||||
|
|
||||||
|
run(cli.accounts.follow, acc.username)
|
||||||
|
|
||||||
|
result = run_json(cli.lists.add, list_name, acc.username, "--json")
|
||||||
|
assert result == {}
|
||||||
|
|
||||||
|
[account] = run_json(cli.lists.accounts, list_name, "--json")
|
||||||
|
assert account["username"] == acc.username
|
||||||
|
|
||||||
|
# Account doesn't exist
|
||||||
|
result = run(cli.lists.add, list_name, "does_not_exist", "--json")
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert result.stderr.strip() == "Error: Account not found"
|
||||||
|
|
||||||
|
# List doesn't exist
|
||||||
|
result = run(cli.lists.add, "does_not_exist", acc.username, "--json")
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert result.stderr.strip() == "Error: List not found"
|
||||||
|
|
||||||
|
result = run_json(cli.lists.remove, list_name, acc.username, "--json")
|
||||||
|
assert result == {}
|
||||||
|
|
||||||
|
result = run_json(cli.lists.accounts, list_name, "--json")
|
||||||
|
assert result == []
|
||||||
|
@ -5,15 +5,17 @@ import uuid
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from os import path
|
from os import path
|
||||||
from tests.integration.conftest import ASSETS_DIR, posted_status_id
|
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 toot.utils import get_text
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
|
||||||
def test_post(app, user, run):
|
def test_post(app, user, run):
|
||||||
text = "i wish i was a #lumberjack"
|
text = "i wish i was a #lumberjack"
|
||||||
out = run("post", text)
|
result = run(cli.post.post, text)
|
||||||
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()
|
status = api.fetch_status(app, user, status_id).json()
|
||||||
assert text == get_text(status["content"])
|
assert text == get_text(status["content"])
|
||||||
@ -28,11 +30,18 @@ def test_post(app, user, run):
|
|||||||
assert status["application"]["website"] == CLIENT_WEBSITE
|
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):
|
def test_post_json(run):
|
||||||
content = "i wish i was a #lumberjack"
|
content = "i wish i was a #lumberjack"
|
||||||
out = run("post", content, "--json")
|
result = run(cli.post.post, content, "--json")
|
||||||
status = json.loads(out)
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
status = json.loads(result.stdout)
|
||||||
assert get_text(status["content"]) == content
|
assert get_text(status["content"]) == content
|
||||||
assert status["visibility"] == "public"
|
assert status["visibility"] == "public"
|
||||||
assert status["sensitive"] is False
|
assert status["sensitive"] is False
|
||||||
@ -42,8 +51,10 @@ def test_post_json(run):
|
|||||||
|
|
||||||
def test_post_visibility(app, user, run):
|
def test_post_visibility(app, user, run):
|
||||||
for visibility in ["public", "unlisted", "private", "direct"]:
|
for visibility in ["public", "unlisted", "private", "direct"]:
|
||||||
out = run("post", "foo", "--visibility", visibility)
|
result = run(cli.post.post, "foo", "--visibility", visibility)
|
||||||
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()
|
status = api.fetch_status(app, user, status_id).json()
|
||||||
assert status["visibility"] == visibility
|
assert status["visibility"] == visibility
|
||||||
|
|
||||||
@ -52,14 +63,23 @@ def test_post_scheduled_at(app, user, run):
|
|||||||
text = str(uuid.uuid4())
|
text = str(uuid.uuid4())
|
||||||
scheduled_at = datetime.now(timezone.utc).replace(microsecond=0) + timedelta(minutes=10)
|
scheduled_at = datetime.now(timezone.utc).replace(microsecond=0) + timedelta(minutes=10)
|
||||||
|
|
||||||
out = run("post", text, "--scheduled-at", scheduled_at.isoformat())
|
result = run(cli.post.post, text, "--scheduled-at", scheduled_at.isoformat())
|
||||||
assert "Toot scheduled for" in out
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
assert "Toot scheduled for" in result.stdout
|
||||||
|
|
||||||
statuses = api.scheduled_statuses(app, user)
|
statuses = api.scheduled_statuses(app, user)
|
||||||
[status] = [s for s in statuses if s["params"]["text"] == text]
|
[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
|
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):
|
def test_post_scheduled_in(app, user, run):
|
||||||
text = str(uuid.uuid4())
|
text = str(uuid.uuid4())
|
||||||
|
|
||||||
@ -76,9 +96,11 @@ def test_post_scheduled_in(app, user, run):
|
|||||||
|
|
||||||
datetimes = []
|
datetimes = []
|
||||||
for scheduled_in, delta in variants:
|
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
|
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)
|
datetimes.append(dttm)
|
||||||
|
|
||||||
scheduled = api.scheduled_statuses(app, user)
|
scheduled = api.scheduled_statuses(app, user)
|
||||||
@ -92,18 +114,31 @@ def test_post_scheduled_in(app, user, run):
|
|||||||
assert delta.total_seconds() < 5
|
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):
|
def test_post_poll(app, user, run):
|
||||||
text = str(uuid.uuid4())
|
text = str(uuid.uuid4())
|
||||||
|
|
||||||
out = run(
|
result = run(
|
||||||
"post", text,
|
cli.post.post, text,
|
||||||
"--poll-option", "foo",
|
"--poll-option", "foo",
|
||||||
"--poll-option", "bar",
|
"--poll-option", "bar",
|
||||||
"--poll-option", "baz",
|
"--poll-option", "baz",
|
||||||
"--poll-option", "qux",
|
"--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()
|
status = api.fetch_status(app, user, status_id).json()
|
||||||
assert status["poll"]["expired"] is False
|
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):
|
def test_post_poll_multiple(app, user, run):
|
||||||
text = str(uuid.uuid4())
|
text = str(uuid.uuid4())
|
||||||
|
|
||||||
out = run(
|
result = run(
|
||||||
"post", text,
|
cli.post.post, text,
|
||||||
"--poll-option", "foo",
|
"--poll-option", "foo",
|
||||||
"--poll-option", "bar",
|
"--poll-option", "bar",
|
||||||
"--poll-multiple"
|
"--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()
|
status = api.fetch_status(app, user, status_id).json()
|
||||||
assert status["poll"]["multiple"] is True
|
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):
|
def test_post_poll_expires_in(app, user, run):
|
||||||
text = str(uuid.uuid4())
|
text = str(uuid.uuid4())
|
||||||
|
|
||||||
out = run(
|
result = run(
|
||||||
"post", text,
|
cli.post.post, text,
|
||||||
"--poll-option", "foo",
|
"--poll-option", "foo",
|
||||||
"--poll-option", "bar",
|
"--poll-option", "bar",
|
||||||
"--poll-expires-in", "8h",
|
"--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()
|
status = api.fetch_status(app, user, status_id).json()
|
||||||
actual = datetime.strptime(status["poll"]["expires_at"], "%Y-%m-%dT%H:%M:%S.%f%z")
|
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):
|
def test_post_poll_hide_totals(app, user, run):
|
||||||
text = str(uuid.uuid4())
|
text = str(uuid.uuid4())
|
||||||
|
|
||||||
out = run(
|
result = run(
|
||||||
"post", text,
|
cli.post.post, text,
|
||||||
"--poll-option", "foo",
|
"--poll-option", "foo",
|
||||||
"--poll-option", "bar",
|
"--poll-option", "bar",
|
||||||
"--poll-hide-totals"
|
"--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()
|
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):
|
def test_post_language(app, user, run):
|
||||||
out = run("post", "test", "--language", "hr")
|
result = run(cli.post.post, "test", "--language", "hr")
|
||||||
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()
|
status = api.fetch_status(app, user, status_id).json()
|
||||||
assert status["language"] == "hr"
|
assert status["language"] == "hr"
|
||||||
|
|
||||||
out = run("post", "test", "--language", "zh")
|
result = run(cli.post.post, "test", "--language", "zh")
|
||||||
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()
|
status = api.fetch_status(app, user, status_id).json()
|
||||||
assert status["language"] == "zh"
|
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):
|
def test_media_thumbnail(app, user, run):
|
||||||
video_path = path.join(ASSETS_DIR, "small.webm")
|
video_path = path.join(ASSETS_DIR, "small.webm")
|
||||||
thumbnail_path = path.join(ASSETS_DIR, "test1.png")
|
thumbnail_path = path.join(ASSETS_DIR, "test1.png")
|
||||||
|
|
||||||
out = run(
|
result = run(
|
||||||
"post",
|
cli.post.post,
|
||||||
"--media", video_path,
|
"--media", video_path,
|
||||||
"--thumbnail", thumbnail_path,
|
"--thumbnail", thumbnail_path,
|
||||||
"--description", "foo",
|
"--description", "foo",
|
||||||
"some text"
|
"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()
|
status = api.fetch_status(app, user, status_id).json()
|
||||||
[media] = status["media_attachments"]
|
[media] = status["media_attachments"]
|
||||||
|
|
||||||
@ -227,8 +275,8 @@ def test_media_attachments(app, user, run):
|
|||||||
path3 = path.join(ASSETS_DIR, "test3.png")
|
path3 = path.join(ASSETS_DIR, "test3.png")
|
||||||
path4 = path.join(ASSETS_DIR, "test4.png")
|
path4 = path.join(ASSETS_DIR, "test4.png")
|
||||||
|
|
||||||
out = run(
|
result = run(
|
||||||
"post",
|
cli.post.post,
|
||||||
"--media", path1,
|
"--media", path1,
|
||||||
"--media", path2,
|
"--media", path2,
|
||||||
"--media", path3,
|
"--media", path3,
|
||||||
@ -239,8 +287,9 @@ def test_media_attachments(app, user, run):
|
|||||||
"--description", "Test 4",
|
"--description", "Test 4",
|
||||||
"some text"
|
"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()
|
status = api.fetch_status(app, user, status_id).json()
|
||||||
|
|
||||||
[a1, a2, a3, a4] = status["media_attachments"]
|
[a1, a2, a3, a4] = status["media_attachments"]
|
||||||
@ -258,6 +307,13 @@ def test_media_attachments(app, user, run):
|
|||||||
assert a4["description"] == "Test 4"
|
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("toot.utils.multiline_input")
|
||||||
@mock.patch("sys.stdin.read")
|
@mock.patch("sys.stdin.read")
|
||||||
def test_media_attachment_without_text(mock_read, mock_ml, app, user, run):
|
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")
|
media_path = path.join(ASSETS_DIR, "test1.png")
|
||||||
|
|
||||||
out = run("post", "--media", media_path)
|
result = run(cli.post.post, "--media", media_path)
|
||||||
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()
|
status = api.fetch_status(app, user, status_id).json()
|
||||||
assert status["content"] == ""
|
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):
|
def test_reply_thread(app, user, friend, run):
|
||||||
status = api.post_status(app, friend, "This is the status").json()
|
status = api.post_status(app, friend, "This is the status").json()
|
||||||
|
|
||||||
out = run("post", "--reply-to", status["id"], "This is the reply")
|
result = run(cli.post.post, "--reply-to", status["id"], "This is the reply")
|
||||||
status_id = posted_status_id(out)
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
status_id = posted_status_id(result.stdout)
|
||||||
reply = api.fetch_status(app, user, status_id).json()
|
reply = api.fetch_status(app, user, status_id).json()
|
||||||
|
|
||||||
assert reply["in_reply_to_id"] == status["id"]
|
assert reply["in_reply_to_id"] == status["id"]
|
||||||
|
|
||||||
out = run("thread", status["id"])
|
result = run(cli.read.thread, status["id"])
|
||||||
[s1, s2] = [s.strip() for s in re.split(r"─+", out) if s.strip()]
|
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 status" in s1
|
||||||
assert "This is the reply" in s2
|
assert "This is the reply" in s2
|
||||||
|
@ -1,45 +1,68 @@
|
|||||||
import json
|
import json
|
||||||
from pprint import pprint
|
|
||||||
import pytest
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from toot import api
|
from tests.integration.conftest import TOOT_TEST_BASE_URL
|
||||||
from toot.entities import Account, from_dict_list
|
from toot import api, cli
|
||||||
from toot.exceptions import ConsoleError
|
from toot.entities import Account, Status, from_dict, from_dict_list
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
|
||||||
def test_instance(app, run):
|
def test_instance_default(app, run):
|
||||||
out = run("instance", "--disable-https")
|
result = run(cli.read.instance)
|
||||||
assert "Mastodon" in out
|
assert result.exit_code == 0
|
||||||
assert app.instance in out
|
|
||||||
assert "running Mastodon" in out
|
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):
|
def test_instance_json(app, run):
|
||||||
out = run("instance", "--json")
|
result = run(cli.read.instance, "--json")
|
||||||
data = json.loads(out)
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
data = json.loads(result.stdout)
|
||||||
assert data["title"] is not None
|
assert data["title"] is not None
|
||||||
assert data["description"] is not None
|
assert data["description"] is not None
|
||||||
assert data["version"] is not None
|
assert data["version"] is not None
|
||||||
|
|
||||||
|
|
||||||
def test_instance_anon(app, run_anon, base_url):
|
def test_instance_anon(app, run_anon, base_url):
|
||||||
out = run_anon("instance", base_url)
|
result = run_anon(cli.read.instance, base_url)
|
||||||
assert "Mastodon" in out
|
assert result.exit_code == 0
|
||||||
assert app.instance in out
|
|
||||||
assert "running Mastodon" in out
|
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
|
# Need to specify the instance name when running anon
|
||||||
with pytest.raises(ConsoleError) as exc:
|
result = run_anon(cli.read.instance)
|
||||||
run_anon("instance")
|
assert result.exit_code == 1
|
||||||
assert str(exc.value) == "Please specify an instance."
|
assert result.stderr.strip() == "Error: INSTANCE argument not given and not logged in"
|
||||||
|
|
||||||
|
|
||||||
def test_whoami(user, run):
|
def test_whoami(user, run):
|
||||||
out = run("whoami")
|
result = run(cli.read.whoami)
|
||||||
# TODO: test other fields once updating account is supported
|
assert result.exit_code == 0
|
||||||
assert f"@{user.username}" in out
|
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):
|
def test_whois(app, friend, run):
|
||||||
@ -51,18 +74,33 @@ def test_whois(app, friend, run):
|
|||||||
]
|
]
|
||||||
|
|
||||||
for username in variants:
|
for username in variants:
|
||||||
out = run("whois", username)
|
result = run(cli.read.whois, username)
|
||||||
assert f"@{friend.username}" in out
|
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):
|
def test_search_account(friend, run):
|
||||||
out = run("search", friend.username)
|
result = run(cli.read.search, friend.username)
|
||||||
assert out == f"Accounts:\n* @{friend.username}"
|
assert result.exit_code == 0
|
||||||
|
assert result.stdout.strip() == f"Accounts:\n* @{friend.username}"
|
||||||
|
|
||||||
|
|
||||||
def test_search_account_json(friend, run_json):
|
def test_search_account_json(friend, run):
|
||||||
out = run_json("search", friend.username, "--json")
|
result = run(cli.read.search, friend.username, "--json")
|
||||||
[account] = from_dict_list(Account, out["accounts"])
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
data = json.loads(result.stdout)
|
||||||
|
[account] = from_dict_list(Account, data["accounts"])
|
||||||
assert account.acct == friend.username
|
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_y")
|
||||||
api.post_status(app, user, "#hashtag_z")
|
api.post_status(app, user, "#hashtag_z")
|
||||||
|
|
||||||
out = run("search", "#hashtag")
|
result = run(cli.read.search, "#hashtag")
|
||||||
assert out == "Hashtags:\n#hashtag_x, #hashtag_y, #hashtag_z"
|
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_x")
|
||||||
api.post_status(app, user, "#hashtag_y")
|
api.post_status(app, user, "#hashtag_y")
|
||||||
api.post_status(app, user, "#hashtag_z")
|
api.post_status(app, user, "#hashtag_z")
|
||||||
|
|
||||||
out = run_json("search", "#hashtag", "--json")
|
result = run(cli.read.search, "#hashtag", "--json")
|
||||||
[h1, h2, h3] = sorted(out["hashtags"], key=lambda h: h["name"])
|
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 h1["name"] == "hashtag_x"
|
||||||
assert h2["name"] == "hashtag_y"
|
assert h2["name"] == "hashtag_y"
|
||||||
assert h3["name"] == "hashtag_z"
|
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):
|
def test_status(app, user, run):
|
||||||
uuid = str(uuid4())
|
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 uuid in out
|
||||||
assert user.username 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):
|
def test_thread(app, user, run):
|
||||||
uuid = str(uuid4())
|
uuid1 = str(uuid4())
|
||||||
s1 = api.post_status(app, user, uuid + "1").json()
|
uuid2 = str(uuid4())
|
||||||
s2 = api.post_status(app, user, uuid + "2", in_reply_to_id=s1["id"]).json()
|
uuid3 = str(uuid4())
|
||||||
s3 = api.post_status(app, user, uuid + "3", in_reply_to_id=s2["id"]).json()
|
|
||||||
|
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]:
|
for status in [s1, s2, s3]:
|
||||||
out = run("thread", status["id"])
|
result = run(cli.read.thread, status["id"])
|
||||||
bits = re.split(r"─+", out)
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
bits = re.split(r"─+", result.stdout.strip())
|
||||||
bits = [b for b in bits if b]
|
bits = [b for b in bits if b]
|
||||||
|
|
||||||
assert len(bits) == 3
|
assert len(bits) == 3
|
||||||
@ -141,6 +178,26 @@ def test_thread(app, user, run):
|
|||||||
assert s2["id"] in bits[1]
|
assert s2["id"] in bits[1]
|
||||||
assert s3["id"] in bits[2]
|
assert s3["id"] in bits[2]
|
||||||
|
|
||||||
assert f"{uuid}1" in bits[0]
|
assert uuid1 in bits[0]
|
||||||
assert f"{uuid}2" in bits[1]
|
assert uuid2 in bits[1]
|
||||||
assert f"{uuid}3" in bits[2]
|
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 time
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from toot import api
|
from toot import api, cli
|
||||||
from toot.exceptions import NotFoundError
|
from toot.exceptions import NotFoundError
|
||||||
|
|
||||||
|
|
||||||
def test_delete(app, user, run):
|
def test_delete(app, user, run):
|
||||||
status = api.post_status(app, user, "foo").json()
|
status = api.post_status(app, user, "foo").json()
|
||||||
|
|
||||||
out = run("delete", status["id"])
|
result = run(cli.statuses.delete, status["id"])
|
||||||
assert out == "✓ Status deleted"
|
assert result.exit_code == 0
|
||||||
|
assert result.stdout.strip() == "✓ Status deleted"
|
||||||
|
|
||||||
with pytest.raises(NotFoundError):
|
with pytest.raises(NotFoundError):
|
||||||
api.fetch_status(app, user, status["id"])
|
api.fetch_status(app, user, status["id"])
|
||||||
@ -19,7 +20,10 @@ def test_delete(app, user, run):
|
|||||||
def test_delete_json(app, user, run):
|
def test_delete_json(app, user, run):
|
||||||
status = api.post_status(app, user, "foo").json()
|
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)
|
result = json.loads(out)
|
||||||
assert result["id"] == status["id"]
|
assert result["id"] == status["id"]
|
||||||
|
|
||||||
@ -31,17 +35,19 @@ def test_favourite(app, user, run):
|
|||||||
status = api.post_status(app, user, "foo").json()
|
status = api.post_status(app, user, "foo").json()
|
||||||
assert not status["favourited"]
|
assert not status["favourited"]
|
||||||
|
|
||||||
out = run("favourite", status["id"])
|
result = run(cli.statuses.favourite, status["id"])
|
||||||
assert out == "✓ Status favourited"
|
assert result.exit_code == 0
|
||||||
|
assert result.stdout.strip() == "✓ Status favourited"
|
||||||
|
|
||||||
status = api.fetch_status(app, user, status["id"]).json()
|
status = api.fetch_status(app, user, status["id"]).json()
|
||||||
assert status["favourited"]
|
assert status["favourited"]
|
||||||
|
|
||||||
out = run("unfavourite", status["id"])
|
result = run(cli.statuses.unfavourite, status["id"])
|
||||||
assert out == "✓ Status unfavourited"
|
assert result.exit_code == 0
|
||||||
|
assert result.stdout.strip() == "✓ Status unfavourited"
|
||||||
|
|
||||||
# A short delay is required before the server returns new data
|
# 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()
|
status = api.fetch_status(app, user, status["id"]).json()
|
||||||
assert not status["favourited"]
|
assert not status["favourited"]
|
||||||
@ -51,15 +57,17 @@ def test_favourite_json(app, user, run):
|
|||||||
status = api.post_status(app, user, "foo").json()
|
status = api.post_status(app, user, "foo").json()
|
||||||
assert not status["favourited"]
|
assert not status["favourited"]
|
||||||
|
|
||||||
out = run("favourite", status["id"], "--json")
|
result = run(cli.statuses.favourite, status["id"], "--json")
|
||||||
result = json.loads(out)
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
result = json.loads(result.stdout)
|
||||||
assert result["id"] == status["id"]
|
assert result["id"] == status["id"]
|
||||||
assert result["favourited"] is True
|
assert result["favourited"] is True
|
||||||
|
|
||||||
out = run("unfavourite", status["id"], "--json")
|
result = run(cli.statuses.unfavourite, status["id"], "--json")
|
||||||
result = json.loads(out)
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
result = json.loads(result.stdout)
|
||||||
assert result["id"] == status["id"]
|
assert result["id"] == status["id"]
|
||||||
assert result["favourited"] is False
|
assert result["favourited"] is False
|
||||||
|
|
||||||
@ -68,17 +76,24 @@ def test_reblog(app, user, run):
|
|||||||
status = api.post_status(app, user, "foo").json()
|
status = api.post_status(app, user, "foo").json()
|
||||||
assert not status["reblogged"]
|
assert not status["reblogged"]
|
||||||
|
|
||||||
out = run("reblog", status["id"])
|
result = run(cli.statuses.reblogged_by, status["id"])
|
||||||
assert out == "✓ Status reblogged"
|
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()
|
status = api.fetch_status(app, user, status["id"]).json()
|
||||||
assert status["reblogged"]
|
assert status["reblogged"]
|
||||||
|
|
||||||
out = run("reblogged_by", status["id"])
|
result = run(cli.statuses.reblogged_by, status["id"])
|
||||||
assert user.username in out
|
assert result.exit_code == 0
|
||||||
|
assert user.username in result.stdout
|
||||||
|
|
||||||
out = run("unreblog", status["id"])
|
result = run(cli.statuses.unreblog, status["id"])
|
||||||
assert out == "✓ Status unreblogged"
|
assert result.exit_code == 0
|
||||||
|
assert result.stdout.strip() == "✓ Status unreblogged"
|
||||||
|
|
||||||
status = api.fetch_status(app, user, status["id"]).json()
|
status = api.fetch_status(app, user, status["id"]).json()
|
||||||
assert not status["reblogged"]
|
assert not status["reblogged"]
|
||||||
@ -88,19 +103,23 @@ def test_reblog_json(app, user, run):
|
|||||||
status = api.post_status(app, user, "foo").json()
|
status = api.post_status(app, user, "foo").json()
|
||||||
assert not status["reblogged"]
|
assert not status["reblogged"]
|
||||||
|
|
||||||
out = run("reblog", status["id"], "--json")
|
result = run(cli.statuses.reblog, status["id"], "--json")
|
||||||
result = json.loads(out)
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
result = json.loads(result.stdout)
|
||||||
assert result["reblogged"] is True
|
assert result["reblogged"] is True
|
||||||
assert result["reblog"]["id"] == status["id"]
|
assert result["reblog"]["id"] == status["id"]
|
||||||
|
|
||||||
out = run("reblogged_by", status["id"], "--json")
|
result = run(cli.statuses.reblogged_by, status["id"], "--json")
|
||||||
[reblog] = json.loads(out)
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
[reblog] = json.loads(result.stdout)
|
||||||
assert reblog["acct"] == user.username
|
assert reblog["acct"] == user.username
|
||||||
|
|
||||||
out = run("unreblog", status["id"], "--json")
|
result = run(cli.statuses.unreblog, status["id"], "--json")
|
||||||
result = json.loads(out)
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
result = json.loads(result.stdout)
|
||||||
assert result["reblogged"] is False
|
assert result["reblogged"] is False
|
||||||
assert result["reblog"] is None
|
assert result["reblog"] is None
|
||||||
|
|
||||||
@ -109,14 +128,16 @@ def test_pin(app, user, run):
|
|||||||
status = api.post_status(app, user, "foo").json()
|
status = api.post_status(app, user, "foo").json()
|
||||||
assert not status["pinned"]
|
assert not status["pinned"]
|
||||||
|
|
||||||
out = run("pin", status["id"])
|
result = run(cli.statuses.pin, status["id"])
|
||||||
assert out == "✓ Status pinned"
|
assert result.exit_code == 0
|
||||||
|
assert result.stdout.strip() == "✓ Status pinned"
|
||||||
|
|
||||||
status = api.fetch_status(app, user, status["id"]).json()
|
status = api.fetch_status(app, user, status["id"]).json()
|
||||||
assert status["pinned"]
|
assert status["pinned"]
|
||||||
|
|
||||||
out = run("unpin", status["id"])
|
result = run(cli.statuses.unpin, status["id"])
|
||||||
assert out == "✓ Status unpinned"
|
assert result.exit_code == 0
|
||||||
|
assert result.stdout.strip() == "✓ Status unpinned"
|
||||||
|
|
||||||
status = api.fetch_status(app, user, status["id"]).json()
|
status = api.fetch_status(app, user, status["id"]).json()
|
||||||
assert not status["pinned"]
|
assert not status["pinned"]
|
||||||
@ -126,15 +147,17 @@ def test_pin_json(app, user, run):
|
|||||||
status = api.post_status(app, user, "foo").json()
|
status = api.post_status(app, user, "foo").json()
|
||||||
assert not status["pinned"]
|
assert not status["pinned"]
|
||||||
|
|
||||||
out = run("pin", status["id"], "--json")
|
result = run(cli.statuses.pin, status["id"], "--json")
|
||||||
result = json.loads(out)
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
result = json.loads(result.stdout)
|
||||||
assert result["pinned"] is True
|
assert result["pinned"] is True
|
||||||
assert result["id"] == status["id"]
|
assert result["id"] == status["id"]
|
||||||
|
|
||||||
out = run("unpin", status["id"], "--json")
|
result = run(cli.statuses.unpin, status["id"], "--json")
|
||||||
result = json.loads(out)
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
result = json.loads(result.stdout)
|
||||||
assert result["pinned"] is False
|
assert result["pinned"] is False
|
||||||
assert result["id"] == status["id"]
|
assert result["id"] == status["id"]
|
||||||
|
|
||||||
@ -143,14 +166,16 @@ def test_bookmark(app, user, run):
|
|||||||
status = api.post_status(app, user, "foo").json()
|
status = api.post_status(app, user, "foo").json()
|
||||||
assert not status["bookmarked"]
|
assert not status["bookmarked"]
|
||||||
|
|
||||||
out = run("bookmark", status["id"])
|
result = run(cli.statuses.bookmark, status["id"])
|
||||||
assert out == "✓ Status bookmarked"
|
assert result.exit_code == 0
|
||||||
|
assert result.stdout.strip() == "✓ Status bookmarked"
|
||||||
|
|
||||||
status = api.fetch_status(app, user, status["id"]).json()
|
status = api.fetch_status(app, user, status["id"]).json()
|
||||||
assert status["bookmarked"]
|
assert status["bookmarked"]
|
||||||
|
|
||||||
out = run("unbookmark", status["id"])
|
result = run(cli.statuses.unbookmark, status["id"])
|
||||||
assert out == "✓ Status unbookmarked"
|
assert result.exit_code == 0
|
||||||
|
assert result.stdout.strip() == "✓ Status unbookmarked"
|
||||||
|
|
||||||
status = api.fetch_status(app, user, status["id"]).json()
|
status = api.fetch_status(app, user, status["id"]).json()
|
||||||
assert not status["bookmarked"]
|
assert not status["bookmarked"]
|
||||||
@ -160,14 +185,16 @@ def test_bookmark_json(app, user, run):
|
|||||||
status = api.post_status(app, user, "foo").json()
|
status = api.post_status(app, user, "foo").json()
|
||||||
assert not status["bookmarked"]
|
assert not status["bookmarked"]
|
||||||
|
|
||||||
out = run("bookmark", status["id"], "--json")
|
result = run(cli.statuses.bookmark, status["id"], "--json")
|
||||||
result = json.loads(out)
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
result = json.loads(result.stdout)
|
||||||
assert result["id"] == status["id"]
|
assert result["id"] == status["id"]
|
||||||
assert result["bookmarked"] is True
|
assert result["bookmarked"] is True
|
||||||
|
|
||||||
out = run("unbookmark", status["id"], "--json")
|
result = run(cli.statuses.unbookmark, status["id"], "--json")
|
||||||
result = json.loads(out)
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
result = json.loads(result.stdout)
|
||||||
assert result["id"] == status["id"]
|
assert result["id"] == status["id"]
|
||||||
assert result["bookmarked"] is False
|
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())).json()
|
||||||
|
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)
|
|
@ -60,6 +60,7 @@ def test_extract_active_when_no_active_user(sample_config):
|
|||||||
|
|
||||||
|
|
||||||
def test_save_app(sample_config):
|
def test_save_app(sample_config):
|
||||||
|
pytest.skip("TODO: fix mocking")
|
||||||
app = App('xxx.yyy', 2, 3, 4)
|
app = App('xxx.yyy', 2, 3, 4)
|
||||||
app2 = App('moo.foo', 5, 6, 7)
|
app2 = App('moo.foo', 5, 6, 7)
|
||||||
|
|
||||||
@ -106,6 +107,7 @@ def test_save_app(sample_config):
|
|||||||
|
|
||||||
|
|
||||||
def test_delete_app(sample_config):
|
def test_delete_app(sample_config):
|
||||||
|
pytest.skip("TODO: fix mocking")
|
||||||
app = App('foo.social', 2, 3, 4)
|
app = App('foo.social', 2, 3, 4)
|
||||||
|
|
||||||
app_count = len(sample_config['apps'])
|
app_count = len(sample_config['apps'])
|
||||||
|
@ -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
|
import pytest
|
||||||
import sys
|
|
||||||
from toot.console import duration
|
from toot.cli.validators import validate_duration
|
||||||
from toot.wcstring import wc_wrap, trunc, pad, fit_text
|
from toot.wcstring import wc_wrap, trunc, pad, fit_text
|
||||||
from toot.tui.utils import ImageCache
|
from toot.tui.utils import ImageCache
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
@ -166,6 +166,9 @@ def test_wc_wrap_indented():
|
|||||||
|
|
||||||
|
|
||||||
def test_duration():
|
def test_duration():
|
||||||
|
def duration(value):
|
||||||
|
return validate_duration(None, None, value)
|
||||||
|
|
||||||
# Long hand
|
# Long hand
|
||||||
assert duration("1 second") == 1
|
assert duration("1 second") == 1
|
||||||
assert duration("1 seconds") == 1
|
assert duration("1 seconds") == 1
|
||||||
@ -193,17 +196,17 @@ def test_duration():
|
|||||||
assert duration("5d 10h 3m 1s") == 5 * 86400 + 10 * 3600 + 3 * 60 + 1
|
assert duration("5d 10h 3m 1s") == 5 * 86400 + 10 * 3600 + 3 * 60 + 1
|
||||||
assert duration("5d10h3m1s") == 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("")
|
duration("")
|
||||||
|
|
||||||
with pytest.raises(ArgumentTypeError):
|
with pytest.raises(click.BadParameter):
|
||||||
duration("100")
|
duration("100")
|
||||||
|
|
||||||
# Wrong order
|
# Wrong order
|
||||||
with pytest.raises(ArgumentTypeError):
|
with pytest.raises(click.BadParameter):
|
||||||
duration("1m1d")
|
duration("1m1d")
|
||||||
|
|
||||||
with pytest.raises(ArgumentTypeError):
|
with pytest.raises(click.BadParameter):
|
||||||
duration("banana")
|
duration("banana")
|
||||||
|
|
||||||
|
|
||||||
|
@ -2,12 +2,23 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
from os.path import join, expanduser
|
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'
|
DEFAULT_INSTANCE = 'https://mastodon.social'
|
||||||
|
|
||||||
|
@ -1,3 +1,3 @@
|
|||||||
from .console import main
|
from toot.cli import cli
|
||||||
|
|
||||||
main()
|
cli()
|
||||||
|
139
toot/api.py
139
toot/api.py
@ -8,7 +8,7 @@ from typing import BinaryIO, List, Optional
|
|||||||
from urllib.parse import urlparse, urlencode, quote
|
from urllib.parse import urlparse, urlencode, quote
|
||||||
|
|
||||||
from toot import App, User, http, CLIENT_NAME, CLIENT_WEBSITE
|
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
|
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)
|
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}"
|
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):
|
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()
|
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'
|
url = app.base_url + '/oauth/token'
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
@ -152,16 +152,10 @@ def login(app, username, password):
|
|||||||
'scope': SCOPES,
|
'scope': SCOPES,
|
||||||
}
|
}
|
||||||
|
|
||||||
response = http.anon_post(url, data=data, allow_redirects=False)
|
return http.anon_post(url, data=data).json()
|
||||||
|
|
||||||
# If auth fails, it redirects to the login page
|
|
||||||
if response.is_redirect:
|
|
||||||
raise AuthenticationError()
|
|
||||||
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
|
|
||||||
def get_browser_login_url(app):
|
def get_browser_login_url(app: App) -> str:
|
||||||
"""Returns the URL for manual log in via browser"""
|
"""Returns the URL for manual log in via browser"""
|
||||||
return "{}/oauth/authorize/?{}".format(app.base_url, urlencode({
|
return "{}/oauth/authorize/?{}".format(app.base_url, urlencode({
|
||||||
"response_type": "code",
|
"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'
|
url = app.base_url + '/oauth/token'
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
@ -306,6 +300,28 @@ def reblogged_by(app, user, status_id) -> Response:
|
|||||||
return http.get(app, user, url)
|
return http.get(app, user, url)
|
||||||
|
|
||||||
|
|
||||||
|
def get_timeline_generator(
|
||||||
|
app: Optional[App],
|
||||||
|
user: Optional[User],
|
||||||
|
account: Optional[str] = None,
|
||||||
|
list_id: Optional[str] = None,
|
||||||
|
tag: Optional[str] = None,
|
||||||
|
local: bool = False,
|
||||||
|
public: bool = False,
|
||||||
|
limit: int = 20, # TODO
|
||||||
|
):
|
||||||
|
if public:
|
||||||
|
return public_timeline_generator(app, user, local=local, limit=limit)
|
||||||
|
elif tag:
|
||||||
|
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):
|
def _get_next_path(headers):
|
||||||
"""Given timeline response headers, returns the path to the next batch"""
|
"""Given timeline response headers, returns the path to the next batch"""
|
||||||
links = headers.get('Link', '')
|
links = headers.get('Link', '')
|
||||||
@ -315,6 +331,14 @@ def _get_next_path(headers):
|
|||||||
return "?".join([parsed.path, parsed.query])
|
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):
|
def _timeline_generator(app, user, path, params=None):
|
||||||
while path:
|
while path:
|
||||||
response = http.get(app, user, path, params)
|
response = http.get(app, user, path, params)
|
||||||
@ -375,7 +399,7 @@ def conversation_timeline_generator(app, user, limit=20):
|
|||||||
return _conversation_timeline_generator(app, user, path, params)
|
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)
|
account = find_account(app, user, account_name)
|
||||||
path = f"/api/v1/accounts/{account['id']}/statuses"
|
path = f"/api/v1/accounts/{account['id']}/statuses"
|
||||||
params = {"limit": limit, "exclude_replies": not replies, "exclude_reblogs": not reblogs}
|
params = {"limit": limit, "exclude_replies": not replies, "exclude_reblogs": not reblogs}
|
||||||
@ -387,24 +411,23 @@ def timeline_list_generator(app, user, list_id, limit=20):
|
|||||||
return _timeline_generator(app, user, path, {'limit': limit})
|
return _timeline_generator(app, user, path, {'limit': limit})
|
||||||
|
|
||||||
|
|
||||||
def _anon_timeline_generator(instance, path, params=None):
|
def _anon_timeline_generator(url, params=None):
|
||||||
while path:
|
while url:
|
||||||
url = f"https://{instance}{path}"
|
|
||||||
response = http.anon_get(url, params)
|
response = http.anon_get(url, params)
|
||||||
yield response.json()
|
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):
|
def anon_public_timeline_generator(base_url, local=False, limit=20):
|
||||||
path = '/api/v1/timelines/public'
|
query = urlencode({"local": str_bool(local), "limit": limit})
|
||||||
params = {'local': str_bool(local), 'limit': limit}
|
url = f"{base_url}/api/v1/timelines/public?{query}"
|
||||||
return _anon_timeline_generator(instance, path, params)
|
return _anon_timeline_generator(url)
|
||||||
|
|
||||||
|
|
||||||
def anon_tag_timeline_generator(instance, hashtag, local=False, limit=20):
|
def anon_tag_timeline_generator(base_url, hashtag, local=False, limit=20):
|
||||||
path = f"/api/v1/timelines/tag/{quote(hashtag)}"
|
query = urlencode({"local": str_bool(local), "limit": limit})
|
||||||
params = {'local': str_bool(local), 'limit': limit}
|
url = f"{base_url}/api/v1/timelines/tag/{quote(hashtag)}?{query}"
|
||||||
return _anon_timeline_generator(instance, path, params)
|
return _anon_timeline_generator(url)
|
||||||
|
|
||||||
|
|
||||||
def get_media(app: App, user: User, id: str):
|
def get_media(app: App, user: User, id: str):
|
||||||
@ -427,7 +450,7 @@ def upload_media(
|
|||||||
"thumbnail": _add_mime_type(thumbnail)
|
"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):
|
def _add_mime_type(file):
|
||||||
@ -469,11 +492,11 @@ def unfollow(app, user, account):
|
|||||||
return _account_action(app, user, account, 'unfollow')
|
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')
|
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')
|
return _tag_action(app, user, tag_name, 'unfollow')
|
||||||
|
|
||||||
|
|
||||||
@ -501,6 +524,43 @@ def followed_tags(app, user):
|
|||||||
return _get_response_list(app, user, path)
|
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):
|
def whois(app, user, account):
|
||||||
return http.get(app, user, f'/api/v1/accounts/{account}').json()
|
return http.get(app, user, f'/api/v1/accounts/{account}').json()
|
||||||
|
|
||||||
@ -544,8 +604,8 @@ def verify_credentials(app, user) -> Response:
|
|||||||
return http.get(app, user, '/api/v1/accounts/verify_credentials')
|
return http.get(app, user, '/api/v1/accounts/verify_credentials')
|
||||||
|
|
||||||
|
|
||||||
def get_notifications(app, user, exclude_types=[], limit=20):
|
def get_notifications(app, user, types=[], exclude_types=[], limit=20):
|
||||||
params = {"exclude_types[]": exclude_types, "limit": limit}
|
params = {"types[]": types, "exclude_types[]": exclude_types, "limit": limit}
|
||||||
return http.get(app, user, '/api/v1/notifications', params).json()
|
return http.get(app, user, '/api/v1/notifications', params).json()
|
||||||
|
|
||||||
|
|
||||||
@ -559,16 +619,7 @@ def get_instance(base_url: str) -> Response:
|
|||||||
|
|
||||||
|
|
||||||
def get_lists(app, user):
|
def get_lists(app, user):
|
||||||
path = "/api/v1/lists"
|
return http.get(app, user, "/api/v1/lists").json()
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def get_list_accounts(app, user, list_id):
|
def get_list_accounts(app, user, list_id):
|
||||||
@ -576,12 +627,12 @@ def get_list_accounts(app, user, list_id):
|
|||||||
return _get_response_list(app, user, path)
|
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"
|
url = "/api/v1/lists"
|
||||||
json = {'title': title}
|
json = {'title': title}
|
||||||
if replies_policy:
|
if replies_policy:
|
||||||
json['replies_policy'] = replies_policy
|
json['replies_policy'] = replies_policy
|
||||||
return http.post(app, user, url, json=json).json()
|
return http.post(app, user, url, json=json)
|
||||||
|
|
||||||
|
|
||||||
def delete_list(app, user, id):
|
def delete_list(app, user, id):
|
||||||
@ -591,7 +642,7 @@ def delete_list(app, user, id):
|
|||||||
def add_accounts_to_list(app, user, list_id, account_ids):
|
def add_accounts_to_list(app, user, list_id, account_ids):
|
||||||
url = f"/api/v1/lists/{list_id}/accounts"
|
url = f"/api/v1/lists/{list_id}/accounts"
|
||||||
json = {'account_ids': account_ids}
|
json = {'account_ids': account_ids}
|
||||||
return http.post(app, user, url, json=json).json()
|
return http.post(app, user, url, json=json)
|
||||||
|
|
||||||
|
|
||||||
def remove_accounts_from_list(app, user, list_id, account_ids):
|
def remove_accounts_from_list(app, user, list_id, account_ids):
|
||||||
|
133
toot/auth.py
133
toot/auth.py
@ -1,18 +1,19 @@
|
|||||||
import sys
|
from toot import api, config, User, App
|
||||||
import webbrowser
|
from toot.entities import from_dict, Instance
|
||||||
|
|
||||||
from builtins import input
|
|
||||||
from getpass import getpass
|
|
||||||
|
|
||||||
from toot import api, config, DEFAULT_INSTANCE, User, App
|
|
||||||
from toot.exceptions import ApiError, ConsoleError
|
from toot.exceptions import ApiError, ConsoleError
|
||||||
from toot.output import print_out
|
|
||||||
from urllib.parse import urlparse
|
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:
|
try:
|
||||||
print_out("Registering application...")
|
|
||||||
response = api.create_app(base_url)
|
response = api.create_app(base_url)
|
||||||
except ApiError:
|
except ApiError:
|
||||||
raise ConsoleError("Registration failed.")
|
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'])
|
app = App(domain, base_url, response['client_id'], response['client_secret'])
|
||||||
config.save_app(app)
|
config.save_app(app)
|
||||||
|
|
||||||
print_out("Application tokens saved.")
|
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
def create_app_interactive(base_url):
|
def get_or_create_app(base_url: str) -> App:
|
||||||
if not base_url:
|
instance = find_instance(base_url)
|
||||||
print_out(f"Enter instance URL [<green>{DEFAULT_INSTANCE}</green>]: ", end="")
|
domain = _get_instance_domain(instance)
|
||||||
base_url = input()
|
|
||||||
if not base_url:
|
|
||||||
base_url = DEFAULT_INSTANCE
|
|
||||||
|
|
||||||
domain = get_instance_domain(base_url)
|
|
||||||
|
|
||||||
return config.load_app(domain) or register_app(domain, base_url)
|
return config.load_app(domain) or register_app(domain, base_url)
|
||||||
|
|
||||||
|
|
||||||
def get_instance_domain(base_url):
|
def create_user(app: App, access_token: str) -> User:
|
||||||
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):
|
|
||||||
# Username is not yet known at this point, so fetch it from Mastodon
|
# Username is not yet known at this point, so fetch it from Mastodon
|
||||||
user = User(app.instance, None, access_token)
|
user = User(app.instance, None, access_token)
|
||||||
creds = api.verify_credentials(app, user).json()
|
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)
|
config.save_user(user, activate=True)
|
||||||
|
|
||||||
print_out("Access token saved to config at: <green>{}</green>".format(
|
|
||||||
config.get_config_file_path()))
|
|
||||||
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
def login_interactive(app, email=None):
|
def login_username_password(app: App, email: str, password: str) -> User:
|
||||||
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>")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
print_out("Authenticating...")
|
|
||||||
response = api.login(app, email, password)
|
response = api.login(app, email, password)
|
||||||
except ApiError:
|
except Exception:
|
||||||
raise ConsoleError("Login failed")
|
raise ConsoleError("Login failed")
|
||||||
|
|
||||||
return create_user(app, response['access_token'])
|
return create_user(app, response["access_token"])
|
||||||
|
|
||||||
|
|
||||||
BROWSER_LOGIN_EXPLANATION = """
|
def login_auth_code(app: App, authorization_code: str) -> User:
|
||||||
This authentication method requires you to log into your Mastodon instance
|
try:
|
||||||
in your browser, where you will be asked to authorize <yellow>toot</yellow> to access
|
response = api.request_access_token(app, authorization_code)
|
||||||
your account. When you do, you will be given an <yellow>authorization code</yellow>
|
except Exception:
|
||||||
which you need to paste here.
|
raise ConsoleError("Login failed")
|
||||||
"""
|
|
||||||
|
return create_user(app, response["access_token"])
|
||||||
|
|
||||||
|
|
||||||
def login_browser_interactive(app):
|
def _get_instance_domain(instance: Instance) -> str:
|
||||||
url = api.get_browser_login_url(app)
|
"""Extracts the instance domain name.
|
||||||
print_out(BROWSER_LOGIN_EXPLANATION)
|
|
||||||
|
|
||||||
print_out("This is the login URL:")
|
Pleroma and its forks return an actual URI here, rather than a domain name
|
||||||
print_out(url)
|
like Mastodon. This is contrary to the spec.¯ in that case, parse out the
|
||||||
print_out("")
|
domain and return it.
|
||||||
|
|
||||||
yesno = input("Open link in default browser? [Y/n]")
|
TODO: when updating to v2 instance endpoint, this field has been renamed to
|
||||||
if not yesno or yesno.lower() == 'y':
|
`domain`
|
||||||
webbrowser.open(url)
|
"""
|
||||||
|
if instance.uri.startswith("http"):
|
||||||
authorization_code = ""
|
return urlparse(instance.uri).netloc
|
||||||
while not authorization_code:
|
return instance.uri
|
||||||
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'])
|
|
||||||
|
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])
|
246
toot/cli/lists.py
Normal file
246
toot/cli/lists.py
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
import click
|
||||||
|
import json as pyjson
|
||||||
|
|
||||||
|
from toot import api, config
|
||||||
|
from toot.cli import Context, cli, pass_context, json_option
|
||||||
|
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()
|
||||||
|
@json_option
|
||||||
|
@pass_context
|
||||||
|
def list(ctx: Context, json: bool):
|
||||||
|
"""List all your lists"""
|
||||||
|
lists = api.get_lists(ctx.app, ctx.user)
|
||||||
|
|
||||||
|
if json:
|
||||||
|
click.echo(pyjson.dumps(lists))
|
||||||
|
else:
|
||||||
|
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")
|
||||||
|
@json_option
|
||||||
|
@pass_context
|
||||||
|
def accounts(ctx: Context, title: str, id: str, json: bool):
|
||||||
|
"""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)
|
||||||
|
|
||||||
|
if json:
|
||||||
|
click.echo(pyjson.dumps(response))
|
||||||
|
else:
|
||||||
|
print_list_accounts(response)
|
||||||
|
|
||||||
|
|
||||||
|
@lists.command()
|
||||||
|
@click.argument("title")
|
||||||
|
@click.option(
|
||||||
|
"--replies-policy",
|
||||||
|
type=click.Choice(["followed", "list", "none"]),
|
||||||
|
default="none",
|
||||||
|
help="Replies policy"
|
||||||
|
)
|
||||||
|
@json_option
|
||||||
|
@pass_context
|
||||||
|
def create(ctx: Context, title: str, replies_policy: str, json: bool):
|
||||||
|
"""Create a list"""
|
||||||
|
response = api.create_list(ctx.app, ctx.user, title=title, replies_policy=replies_policy)
|
||||||
|
if json:
|
||||||
|
print(response.text)
|
||||||
|
else:
|
||||||
|
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")
|
||||||
|
@json_option
|
||||||
|
@pass_context
|
||||||
|
def delete(ctx: Context, title: str, id: str, json: bool):
|
||||||
|
"""Delete a list"""
|
||||||
|
list_id = _get_list_id(ctx, title, id)
|
||||||
|
response = api.delete_list(ctx.app, ctx.user, list_id)
|
||||||
|
if json:
|
||||||
|
click.echo(response.text)
|
||||||
|
else:
|
||||||
|
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")
|
||||||
|
@json_option
|
||||||
|
@pass_context
|
||||||
|
def add(ctx: Context, title: str, account: str, id: str, json: bool):
|
||||||
|
"""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:
|
||||||
|
response = api.add_accounts_to_list(ctx.app, ctx.user, list_id, [found_account["id"]])
|
||||||
|
if json:
|
||||||
|
click.echo(response.text)
|
||||||
|
else:
|
||||||
|
click.secho(f"✓ Added account \"{account}\"", fg="green")
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@lists.command()
|
||||||
|
@click.argument("title", required=False)
|
||||||
|
@click.argument("account")
|
||||||
|
@click.option("--id", help="List ID if not title is given")
|
||||||
|
@json_option
|
||||||
|
@pass_context
|
||||||
|
def remove(ctx: Context, title: str, account: str, id: str, json: bool):
|
||||||
|
"""Remove an account from a list"""
|
||||||
|
list_id = _get_list_id(ctx, title, id)
|
||||||
|
found_account = api.find_account(ctx.app, ctx.user, account)
|
||||||
|
response = api.remove_accounts_from_list(ctx.app, ctx.user, list_id, [found_account["id"]])
|
||||||
|
if json:
|
||||||
|
click.echo(response.text)
|
||||||
|
else:
|
||||||
|
click.secho(f"✓ Removed account \"{account}\"", fg="green")
|
||||||
|
|
||||||
|
|
||||||
|
# -- Deprecated commands -------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@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"])
|
117
toot/cli/read.py
Normal file
117
toot/cli/read.py
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
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, get_context, 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", callback=validate_instance, required=False)
|
||||||
|
@json_option
|
||||||
|
def instance(instance: Optional[str], json: bool):
|
||||||
|
"""Display instance details
|
||||||
|
|
||||||
|
INSTANCE can be a domain or base URL of the instance to display.
|
||||||
|
e.g. 'mastodon.social' or 'https://mastodon.social'. If not
|
||||||
|
given will display details for the currently logged in instance.
|
||||||
|
"""
|
||||||
|
if not instance:
|
||||||
|
context = get_context()
|
||||||
|
if not context.app:
|
||||||
|
raise click.ClickException("INSTANCE argument not given and not logged in")
|
||||||
|
instance = context.app.base_url
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = api.get_instance(instance)
|
||||||
|
except ApiError:
|
||||||
|
raise ConsoleError(
|
||||||
|
f"Instance not found at {instance}.\n" +
|
||||||
|
"The given domain probably does not host a Mastodon instance."
|
||||||
|
)
|
||||||
|
|
||||||
|
if json:
|
||||||
|
click.echo(response.text)
|
||||||
|
else:
|
||||||
|
print_instance(from_dict(Instance, response.json()))
|
||||||
|
|
||||||
|
|
||||||
|
@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")
|
183
toot/cli/timelines.py
Normal file
183
toot/cli/timelines.py
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
import sys
|
||||||
|
import click
|
||||||
|
|
||||||
|
from toot import api
|
||||||
|
from toot.cli import cli, get_context, 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)"
|
||||||
|
)
|
||||||
|
def timeline(
|
||||||
|
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.")
|
||||||
|
|
||||||
|
if public and instance:
|
||||||
|
generator = api.anon_public_timeline_generator(instance, local, count)
|
||||||
|
elif tag and instance:
|
||||||
|
generator = api.anon_tag_timeline_generator(instance, tag, local, count)
|
||||||
|
else:
|
||||||
|
ctx = get_context()
|
||||||
|
list_id = _get_list_id(ctx, list)
|
||||||
|
|
||||||
|
"""Show recent statuses in a timeline"""
|
||||||
|
generator = api.get_timeline_generator(
|
||||||
|
ctx.app,
|
||||||
|
ctx.user,
|
||||||
|
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()
|
|
@ -1,12 +1,12 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from functools import wraps
|
from contextlib import contextmanager
|
||||||
from os.path import dirname, join
|
from os.path import dirname, join
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from toot import User, App, get_config_dir
|
from toot import User, App, get_config_dir
|
||||||
from toot.exceptions import ConsoleError
|
from toot.exceptions import ConsoleError
|
||||||
from toot.output import print_out
|
|
||||||
|
|
||||||
|
|
||||||
TOOT_CONFIG_FILE_NAME = "config.json"
|
TOOT_CONFIG_FILE_NAME = "config.json"
|
||||||
@ -29,8 +29,6 @@ def make_config(path):
|
|||||||
"active_user": None,
|
"active_user": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
print_out("Creating config file at <blue>{}</blue>".format(path))
|
|
||||||
|
|
||||||
# Ensure dir exists
|
# Ensure dir exists
|
||||||
os.makedirs(dirname(path), exist_ok=True)
|
os.makedirs(dirname(path), exist_ok=True)
|
||||||
|
|
||||||
@ -41,6 +39,10 @@ def make_config(path):
|
|||||||
|
|
||||||
|
|
||||||
def load_config():
|
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()
|
path = get_config_file_path()
|
||||||
|
|
||||||
if not os.path.exists(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)
|
return extract_user_app(load_config(), user_id)
|
||||||
|
|
||||||
|
|
||||||
def load_app(instance):
|
def load_app(instance: str) -> Optional[App]:
|
||||||
config = load_config()
|
config = load_config()
|
||||||
if instance in config['apps']:
|
if instance in config['apps']:
|
||||||
return App(**config['apps'][instance])
|
return App(**config['apps'][instance])
|
||||||
@ -106,63 +108,39 @@ def get_user_list():
|
|||||||
return config['users']
|
return config['users']
|
||||||
|
|
||||||
|
|
||||||
def modify_config(f):
|
@contextmanager
|
||||||
@wraps(f)
|
def edit_config():
|
||||||
def wrapper(*args, **kwargs):
|
config = load_config()
|
||||||
config = load_config()
|
yield config
|
||||||
config = f(config, *args, **kwargs)
|
save_config(config)
|
||||||
save_config(config)
|
|
||||||
return config
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
|
|
||||||
@modify_config
|
def save_app(app: App):
|
||||||
def save_app(config, app):
|
with edit_config() as config:
|
||||||
assert isinstance(app, App)
|
config['apps'][app.instance] = app._asdict()
|
||||||
|
|
||||||
config['apps'][app.instance] = app._asdict()
|
|
||||||
|
|
||||||
return config
|
|
||||||
|
|
||||||
|
|
||||||
@modify_config
|
|
||||||
def delete_app(config, app):
|
def delete_app(config, app):
|
||||||
assert isinstance(app, App)
|
with edit_config() as config:
|
||||||
|
config['apps'].pop(app.instance, None)
|
||||||
config['apps'].pop(app.instance, None)
|
|
||||||
|
|
||||||
return config
|
|
||||||
|
|
||||||
|
|
||||||
@modify_config
|
def save_user(user: User, activate=True):
|
||||||
def save_user(config, user, activate=True):
|
with edit_config() as config:
|
||||||
assert isinstance(user, User)
|
config['users'][user_id(user)] = user._asdict()
|
||||||
|
|
||||||
config['users'][user_id(user)] = user._asdict()
|
if activate:
|
||||||
|
config['active_user'] = user_id(user)
|
||||||
|
|
||||||
if activate:
|
|
||||||
|
def delete_user(user: User):
|
||||||
|
with edit_config() as config:
|
||||||
|
config['users'].pop(user_id(user), None)
|
||||||
|
|
||||||
|
if config['active_user'] == user_id(user):
|
||||||
|
config['active_user'] = None
|
||||||
|
|
||||||
|
|
||||||
|
def activate_user(user: User):
|
||||||
|
with edit_config() as config:
|
||||||
config['active_user'] = user_id(user)
|
config['active_user'] = user_id(user)
|
||||||
|
|
||||||
return config
|
|
||||||
|
|
||||||
|
|
||||||
@modify_config
|
|
||||||
def delete_user(config, user):
|
|
||||||
assert isinstance(user, User)
|
|
||||||
|
|
||||||
config['users'].pop(user_id(user), None)
|
|
||||||
|
|
||||||
if config['active_user'] == user_id(user):
|
|
||||||
config['active_user'] = None
|
|
||||||
|
|
||||||
return config
|
|
||||||
|
|
||||||
|
|
||||||
@modify_config
|
|
||||||
def activate_user(config, user):
|
|
||||||
assert isinstance(user, User)
|
|
||||||
|
|
||||||
config['active_user'] = user_id(user)
|
|
||||||
|
|
||||||
return config
|
|
||||||
|
983
toot/console.py
983
toot/console.py
@ -1,983 +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 cache_size(value):
|
|
||||||
"""validates the cache size parameter"""
|
|
||||||
if value.isdigit():
|
|
||||||
size = int(value)
|
|
||||||
else:
|
|
||||||
raise ArgumentTypeError("Cache size must be numeric.")
|
|
||||||
if size > 1024:
|
|
||||||
raise ValueError("Cache size too large: 1024MB maximum.")
|
|
||||||
elif size < 1:
|
|
||||||
raise ValueError("Cache size too small: 1MB minimum.")
|
|
||||||
return size
|
|
||||||
|
|
||||||
|
|
||||||
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.",
|
|
||||||
}),
|
|
||||||
(["--cache-size"], {
|
|
||||||
"type": cache_size,
|
|
||||||
"help": "Specify the image cache maximum size in megabytes. Default: 10MB. Minimum: 1MB.",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
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
|
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
|
# Generic data class instance
|
||||||
T = TypeVar("T")
|
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."""
|
"""Raised when an API request fails for whatever reason."""
|
||||||
|
|
||||||
|
|
||||||
@ -10,5 +13,5 @@ class AuthenticationError(ApiError):
|
|||||||
"""Raised when login fails."""
|
"""Raised when login fails."""
|
||||||
|
|
||||||
|
|
||||||
class ConsoleError(Exception):
|
class ConsoleError(ClickException):
|
||||||
"""Raised when an error occurs which needs to be show to the user."""
|
"""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 re
|
||||||
import sys
|
|
||||||
import textwrap
|
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.entities import Account, Instance, Notification, Poll, Status
|
||||||
|
from toot.utils import get_text, html_to_paragraphs
|
||||||
from toot.wcstring import wc_wrap
|
from toot.wcstring import wc_wrap
|
||||||
from typing import Iterable, List
|
from typing import Any, Generator, Iterable, List
|
||||||
from wcwidth import wcswidth
|
from wcwidth import wcswidth
|
||||||
|
|
||||||
|
|
||||||
STYLES = {
|
DEFAULT_WIDTH = 80
|
||||||
'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)
|
|
||||||
|
|
||||||
|
|
||||||
def colorize(message):
|
def get_max_width() -> int:
|
||||||
"""
|
return click.get_current_context().max_content_width or DEFAULT_WIDTH
|
||||||
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 strip_tags(message):
|
def get_terminal_width() -> int:
|
||||||
return re.sub(STYLE_TAG_PATTERN, "", message)
|
return shutil.get_terminal_size().columns
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=None)
|
def get_width() -> int:
|
||||||
def use_ansi_color():
|
return min(get_terminal_width(), get_max_width())
|
||||||
"""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 print_out(*args, **kwargs):
|
def print_warning(text: str):
|
||||||
if not settings.get_quiet():
|
click.secho(f"Warning: {text}", fg="yellow", err=True)
|
||||||
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_instance(instance: Instance):
|
def print_instance(instance: Instance):
|
||||||
print_out(f"<green>{instance.title}</green>")
|
width = get_width()
|
||||||
print_out(f"<blue>{instance.uri}</blue>")
|
click.echo(instance_to_text(instance, width))
|
||||||
print_out(f"running Mastodon {instance.version}")
|
|
||||||
print_out()
|
|
||||||
|
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:
|
if instance.description:
|
||||||
for paragraph in re.split(r"[\r\n]+", instance.description.strip()):
|
for paragraph in re.split(r"[\r\n]+", instance.description.strip()):
|
||||||
paragraph = get_text(paragraph)
|
paragraph = get_text(paragraph)
|
||||||
print_out(textwrap.fill(paragraph, width=80))
|
yield textwrap.fill(paragraph, width=width)
|
||||||
print_out()
|
yield ""
|
||||||
|
|
||||||
if instance.rules:
|
if instance.rules:
|
||||||
print_out("Rules:")
|
yield "Rules:"
|
||||||
for ordinal, rule in enumerate(instance.rules):
|
for ordinal, rule in enumerate(instance.rules):
|
||||||
ordinal = f"{ordinal + 1}."
|
ordinal = f"{ordinal + 1}."
|
||||||
lines = textwrap.wrap(rule.text, 80 - len(ordinal))
|
lines = textwrap.wrap(rule.text, width - len(ordinal))
|
||||||
first = True
|
first = True
|
||||||
for line in lines:
|
for line in lines:
|
||||||
if first:
|
if first:
|
||||||
print_out(f"{ordinal} {line}")
|
yield f"{ordinal} {line}"
|
||||||
first = False
|
first = False
|
||||||
else:
|
else:
|
||||||
print_out(f"{' ' * len(ordinal)} {line}")
|
yield f"{' ' * len(ordinal)} {line}"
|
||||||
print_out()
|
yield ""
|
||||||
|
|
||||||
contact = instance.contact_account
|
contact = instance.contact_account
|
||||||
if contact:
|
if contact:
|
||||||
print_out(f"Contact: {contact.display_name} @{contact.acct}")
|
yield f"Contact: {contact.display_name} @{contact.acct}"
|
||||||
|
|
||||||
|
|
||||||
def print_account(account: Account):
|
def print_account(account: Account) -> None:
|
||||||
print_out(f"<green>@{account.acct}</green> {account.display_name}")
|
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:
|
if account.note:
|
||||||
print_out("")
|
yield ""
|
||||||
print_html(account.note)
|
yield from html_lines(account.note, width)
|
||||||
|
|
||||||
since = account.created_at.strftime('%Y-%m-%d')
|
yield ""
|
||||||
|
yield f"ID: {green(account.id)}"
|
||||||
print_out("")
|
yield f"Since: {green(since)}"
|
||||||
print_out(f"ID: <green>{account.id}</green>")
|
yield ""
|
||||||
print_out(f"Since: <green>{since}</green>")
|
yield f"Followers: {yellow(account.followers_count)}"
|
||||||
print_out("")
|
yield f"Following: {yellow(account.following_count)}"
|
||||||
print_out(f"Followers: <yellow>{account.followers_count}</yellow>")
|
yield f"Statuses: {yellow(account.statuses_count)}"
|
||||||
print_out(f"Following: <yellow>{account.following_count}</yellow>")
|
|
||||||
print_out(f"Statuses: <yellow>{account.statuses_count}</yellow>")
|
|
||||||
|
|
||||||
if account.fields:
|
if account.fields:
|
||||||
for field in account.fields:
|
for field in account.fields:
|
||||||
name = field.name.title()
|
name = field.name.title()
|
||||||
print_out(f'\n<yellow>{name}</yellow>:')
|
yield f'\n{yellow(name)}:'
|
||||||
print_html(field.value)
|
yield from html_lines(field.value, width)
|
||||||
if field.verified_at:
|
if field.verified_at:
|
||||||
print_out("<green>✓ Verified</green>")
|
yield green("✓ Verified")
|
||||||
|
|
||||||
print_out("")
|
yield ""
|
||||||
print_out(account.url)
|
yield account.url
|
||||||
|
|
||||||
|
|
||||||
HASHTAG_PATTERN = re.compile(r'(?<!\w)(#\w+)\b')
|
|
||||||
|
|
||||||
|
|
||||||
def highlight_hashtags(line):
|
|
||||||
return re.sub(HASHTAG_PATTERN, '<cyan>\\1</cyan>', line)
|
|
||||||
|
|
||||||
|
|
||||||
def print_acct_list(accounts):
|
def print_acct_list(accounts):
|
||||||
for account in accounts:
|
for account in accounts:
|
||||||
print_out(f"* <green>@{account['acct']}</green> {account['display_name']}")
|
acct = green(f"@{account['acct']}")
|
||||||
|
click.echo(f"* {acct} {account['display_name']}")
|
||||||
|
|
||||||
def print_user_list(users):
|
|
||||||
for user in users:
|
|
||||||
print_out(f"* {user}")
|
|
||||||
|
|
||||||
|
|
||||||
def print_tag_list(tags):
|
def print_tag_list(tags):
|
||||||
if tags:
|
for tag in tags:
|
||||||
for tag in tags:
|
click.echo(f"* {format_tag_name(tag)}\t{tag['url']}")
|
||||||
print_out(f"* <green>#{tag['name']}\t</green>{tag['url']}")
|
|
||||||
else:
|
|
||||||
print_out("You're not following any hashtags.")
|
|
||||||
|
|
||||||
|
|
||||||
def print_lists(lists):
|
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 = [[len(cell) for cell in row] for row in data + [headers]]
|
||||||
widths = [max(width) for width in zip(*widths)]
|
widths = [max(width) for width in zip(*widths)]
|
||||||
|
|
||||||
def style(string, tag):
|
def print_row(row):
|
||||||
return f"<{tag}>{string}</{tag}>" if tag else string
|
|
||||||
|
|
||||||
def print_row(row, tag=None):
|
|
||||||
for idx, cell in enumerate(row):
|
for idx, cell in enumerate(row):
|
||||||
width = widths[idx]
|
width = widths[idx]
|
||||||
print_out(style(cell.ljust(width), tag), end="")
|
click.echo(cell.ljust(width), nl=False)
|
||||||
print_out(" ", end="")
|
click.echo(" ", nl=False)
|
||||||
print_out()
|
click.echo()
|
||||||
|
|
||||||
underlines = ["-" * width for width in widths]
|
underlines = ["-" * width for width in widths]
|
||||||
|
|
||||||
print_row(headers, "bold")
|
print_row(headers)
|
||||||
print_row(underlines, "dim")
|
print_row(underlines)
|
||||||
|
|
||||||
for row in data:
|
for row in data:
|
||||||
print_row(row)
|
print_row(row)
|
||||||
@ -255,33 +147,42 @@ def print_table(headers: List[str], data: List[List[str]]):
|
|||||||
|
|
||||||
def print_list_accounts(accounts):
|
def print_list_accounts(accounts):
|
||||||
if accounts:
|
if accounts:
|
||||||
print_out("Accounts in list</green>:\n")
|
click.echo("Accounts in list:\n")
|
||||||
print_acct_list(accounts)
|
print_acct_list(accounts)
|
||||||
else:
|
else:
|
||||||
print_out("This list has no accounts.")
|
click.echo("This list has no accounts.")
|
||||||
|
|
||||||
|
|
||||||
def print_search_results(results):
|
def print_search_results(results):
|
||||||
accounts = results['accounts']
|
accounts = results["accounts"]
|
||||||
hashtags = results['hashtags']
|
hashtags = results["hashtags"]
|
||||||
|
|
||||||
if accounts:
|
if accounts:
|
||||||
print_out("\nAccounts:")
|
click.echo("\nAccounts:")
|
||||||
print_acct_list(accounts)
|
print_acct_list(accounts)
|
||||||
|
|
||||||
if hashtags:
|
if hashtags:
|
||||||
print_out("\nHashtags:")
|
click.echo("\nHashtags:")
|
||||||
print_out(", ".join([f"<green>#{t['name']}</green>" for t in hashtags]))
|
click.echo(", ".join([format_tag_name(tag) for tag in hashtags]))
|
||||||
|
|
||||||
if not accounts and not 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
|
status_id = status.id
|
||||||
in_reply_to_id = status.in_reply_to_id
|
in_reply_to_id = status.in_reply_to_id
|
||||||
reblogged_by = status.account if status.reblog else None
|
reblogged_by = status.account if status.reblog else None
|
||||||
|
|
||||||
status = status.original
|
status = status.original
|
||||||
|
|
||||||
time = status.created_at.strftime('%Y-%m-%d %H:%M %Z')
|
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
|
spacing = width - wcswidth(username) - wcswidth(time) - 2
|
||||||
|
|
||||||
display_name = status.account.display_name
|
display_name = status.account.display_name
|
||||||
|
|
||||||
if display_name:
|
if display_name:
|
||||||
|
author = f"{green(display_name)} {blue(username)}"
|
||||||
spacing -= wcswidth(display_name) + 1
|
spacing -= wcswidth(display_name) + 1
|
||||||
|
else:
|
||||||
|
author = blue(username)
|
||||||
|
|
||||||
print_out(
|
spaces = " " * spacing
|
||||||
f"<green>{display_name}</green>" if display_name else "",
|
yield f"{author} {spaces} {yellow(time)}"
|
||||||
f"<blue>{username}</blue>",
|
|
||||||
" " * spacing,
|
|
||||||
f"<yellow>{time}</yellow>",
|
|
||||||
)
|
|
||||||
|
|
||||||
print_out("")
|
yield ""
|
||||||
print_html(status.content, width)
|
yield from html_lines(status.content, width)
|
||||||
|
|
||||||
if status.media_attachments:
|
if status.media_attachments:
|
||||||
print_out("\nMedia:")
|
yield ""
|
||||||
|
yield "Media:"
|
||||||
for attachment in status.media_attachments:
|
for attachment in status.media_attachments:
|
||||||
url = attachment.url
|
url = attachment.url
|
||||||
for line in wc_wrap(url, width):
|
for line in wc_wrap(url, width):
|
||||||
print_out(line)
|
yield line
|
||||||
|
|
||||||
if status.poll:
|
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(
|
reply = f"↲ In reply to {yellow(in_reply_to_id)} " if in_reply_to_id else ""
|
||||||
f"ID <yellow>{status_id}</yellow> ",
|
boost = f"↻ {blue(reblogged_by_acct)} boosted " if reblogged_by else ""
|
||||||
f"↲ In reply to <yellow>{in_reply_to_id}</yellow> " if in_reply_to_id else "",
|
yield f"ID {yellow(status_id)} {reply} {boost}"
|
||||||
f"↻ <blue>@{reblogged_by.acct}</blue> boosted " if reblogged_by else "",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def print_html(text, width=80):
|
def html_lines(html: str, width: int) -> Generator[str, None, None]:
|
||||||
first = True
|
first = True
|
||||||
for paragraph in html_to_paragraphs(text):
|
for paragraph in html_to_paragraphs(html):
|
||||||
if not first:
|
if not first:
|
||||||
print_out("")
|
yield ""
|
||||||
for line in paragraph:
|
for line in paragraph:
|
||||||
for subline in wc_wrap(line, width):
|
for subline in wc_wrap(line, width):
|
||||||
print_out(highlight_hashtags(subline))
|
yield subline
|
||||||
first = False
|
first = False
|
||||||
|
|
||||||
|
|
||||||
def print_poll(poll: Poll):
|
def poll_lines(poll: Poll) -> Generator[str, None, None]:
|
||||||
print_out()
|
|
||||||
for idx, option in enumerate(poll.options):
|
for idx, option in enumerate(poll.options):
|
||||||
perc = (round(100 * option.votes_count / poll.votes_count)
|
perc = (round(100 * option.votes_count / poll.votes_count)
|
||||||
if poll.votes_count and option.votes_count is not None else 0)
|
if poll.votes_count and option.votes_count is not None else 0)
|
||||||
|
|
||||||
if poll.voted and poll.own_votes and idx in poll.own_votes:
|
if poll.voted and poll.own_votes and idx in poll.own_votes:
|
||||||
voted_for = " <yellow>✓</yellow>"
|
voted_for = yellow(" ✓")
|
||||||
else:
|
else:
|
||||||
voted_for = ""
|
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'
|
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")
|
expires_at = poll.expires_at.strftime("%Y-%m-%d %H:%M")
|
||||||
poll_footer += f" · Closes on {expires_at}"
|
poll_footer += f" · Closes on {expires_at}"
|
||||||
|
|
||||||
print_out()
|
yield ""
|
||||||
print_out(poll_footer)
|
yield poll_footer
|
||||||
|
|
||||||
|
|
||||||
def print_timeline(items: Iterable[Status], width=100):
|
def print_timeline(items: Iterable[Status]):
|
||||||
print_out("─" * width)
|
print_divider()
|
||||||
for item in items:
|
for item in items:
|
||||||
print_status(item, width)
|
print_status(item)
|
||||||
print_out("─" * width)
|
print_divider()
|
||||||
|
|
||||||
|
|
||||||
notification_msgs = {
|
def print_notification(notification: Notification):
|
||||||
"follow": "{account} now follows you",
|
print_notification_header(notification)
|
||||||
"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))
|
|
||||||
if notification.status:
|
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:
|
for notification in notifications:
|
||||||
|
print_divider()
|
||||||
print_notification(notification)
|
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 functools import lru_cache
|
||||||
from os.path import exists, join
|
from os.path import exists, join
|
||||||
from tomlkit import parse
|
from tomlkit import parse
|
||||||
@ -17,7 +14,7 @@ def get_settings_path():
|
|||||||
return join(get_config_dir(), TOOT_SETTINGS_FILE_NAME)
|
return join(get_config_dir(), TOOT_SETTINGS_FILE_NAME)
|
||||||
|
|
||||||
|
|
||||||
def load_settings() -> dict:
|
def _load_settings() -> dict:
|
||||||
# Used for testing without config file
|
# Used for testing without config file
|
||||||
if DISABLE_SETTINGS:
|
if DISABLE_SETTINGS:
|
||||||
return {}
|
return {}
|
||||||
@ -33,7 +30,7 @@ def load_settings() -> dict:
|
|||||||
|
|
||||||
@lru_cache(maxsize=None)
|
@lru_cache(maxsize=None)
|
||||||
def get_settings():
|
def get_settings():
|
||||||
return load_settings()
|
return _load_settings()
|
||||||
|
|
||||||
|
|
||||||
T = TypeVar("T")
|
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 _get_setting(dct[key], keys[1:], type, default)
|
||||||
|
|
||||||
return 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)
|
|
||||||
|
@ -5,9 +5,11 @@ import requests
|
|||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from typing import NamedTuple, Optional
|
||||||
|
|
||||||
from toot import api, config, __version__, settings
|
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 toot.exceptions import ApiError
|
||||||
|
|
||||||
from .compose import StatusComposer
|
from .compose import StatusComposer
|
||||||
@ -30,6 +32,12 @@ urwid.set_encoding('UTF-8')
|
|||||||
DEFAULT_MAX_TOOT_CHARS = 500
|
DEFAULT_MAX_TOOT_CHARS = 500
|
||||||
|
|
||||||
|
|
||||||
|
class TuiOptions(NamedTuple):
|
||||||
|
colors: int
|
||||||
|
media_viewer: Optional[str]
|
||||||
|
relative_datetimes: bool
|
||||||
|
|
||||||
|
|
||||||
class Header(urwid.WidgetWrap):
|
class Header(urwid.WidgetWrap):
|
||||||
def __init__(self, app, user):
|
def __init__(self, app, user):
|
||||||
self.app = app
|
self.app = app
|
||||||
@ -85,9 +93,11 @@ class TUI(urwid.Frame):
|
|||||||
screen: urwid.BaseScreen
|
screen: urwid.BaseScreen
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create(app, user, args):
|
def create(app: App, user: User, args: TuiOptions):
|
||||||
"""Factory method, sets up TUI and an event loop."""
|
"""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)
|
tui = TUI(app, user, screen, args)
|
||||||
|
|
||||||
palette = PALETTE.copy()
|
palette = PALETTE.copy()
|
||||||
@ -106,23 +116,11 @@ class TUI(urwid.Frame):
|
|||||||
|
|
||||||
return tui
|
return tui
|
||||||
|
|
||||||
@staticmethod
|
def __init__(self, app, user, screen, options: TuiOptions):
|
||||||
def create_screen(args):
|
|
||||||
screen = urwid.raw_display.Screen()
|
|
||||||
|
|
||||||
# Determine how many colors to use
|
|
||||||
default_colors = 1 if args.no_color else 16
|
|
||||||
colors = settings.get_setting("tui.colors", int, default_colors)
|
|
||||||
logger.debug(f"Setting colors to {colors}")
|
|
||||||
screen.set_terminal_properties(colors)
|
|
||||||
|
|
||||||
return screen
|
|
||||||
|
|
||||||
def __init__(self, app, user, screen, args):
|
|
||||||
self.app = app
|
self.app = app
|
||||||
self.user = user
|
self.user = user
|
||||||
self.args = args
|
|
||||||
self.config = config.load_config()
|
self.config = config.load_config()
|
||||||
|
self.options = options
|
||||||
|
|
||||||
self.loop = None # late init, set in `create`
|
self.loop = None # late init, set in `create`
|
||||||
self.screen = screen
|
self.screen = screen
|
||||||
@ -144,7 +142,6 @@ class TUI(urwid.Frame):
|
|||||||
self.can_translate = False
|
self.can_translate = False
|
||||||
self.account = None
|
self.account = None
|
||||||
self.followed_accounts = []
|
self.followed_accounts = []
|
||||||
self.media_viewer = settings.get_setting("tui.media_viewer", str)
|
|
||||||
|
|
||||||
if self.args.cache_size:
|
if self.args.cache_size:
|
||||||
self.cache_max = 1024 * 1024 * self.args.cache_size
|
self.cache_max = 1024 * 1024 * self.args.cache_size
|
||||||
@ -513,8 +510,15 @@ class TUI(urwid.Frame):
|
|||||||
if not urls:
|
if not urls:
|
||||||
return
|
return
|
||||||
|
|
||||||
if self.media_viewer:
|
media_viewer = self.options.media_viewer
|
||||||
subprocess.run([self.media_viewer] + urls)
|
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:
|
else:
|
||||||
self.footer.set_error_message("Media viewer not configured")
|
self.footer.set_error_message("Media viewer not configured")
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import urwid
|
import urwid
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from toot.console import get_default_visibility
|
from toot.cli import get_default_visibility
|
||||||
|
|
||||||
from .constants import VISIBILITY_OPTIONS
|
from .constants import VISIBILITY_OPTIONS
|
||||||
from .widgets import Button, EditBox
|
from .widgets import Button, EditBox
|
||||||
|
@ -89,7 +89,7 @@ class Timeline(urwid.Columns):
|
|||||||
return urwid.ListBox(walker)
|
return urwid.ListBox(walker)
|
||||||
|
|
||||||
def build_list_item(self, status):
|
def build_list_item(self, status):
|
||||||
item = StatusListItem(status, self.tui.args.relative_datetimes)
|
item = StatusListItem(status, self.tui.options.relative_datetimes)
|
||||||
urwid.connect_signal(item, "click", lambda *args:
|
urwid.connect_signal(item, "click", lambda *args:
|
||||||
self.tui.show_context_menu(status))
|
self.tui.show_context_menu(status))
|
||||||
return urwid.AttrMap(item, None, focus_map={
|
return urwid.AttrMap(item, None, focus_map={
|
||||||
@ -105,7 +105,7 @@ class Timeline(urwid.Columns):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
poll = status.original.data.get("poll")
|
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 = [
|
options = [
|
||||||
"[A]ccount" if not status.is_mine else "",
|
"[A]ccount" if not status.is_mine else "",
|
||||||
@ -117,7 +117,6 @@ class Timeline(urwid.Columns):
|
|||||||
"[T]hread" if not self.is_thread else "",
|
"[T]hread" if not self.is_thread else "",
|
||||||
"L[i]nks",
|
"L[i]nks",
|
||||||
"[M]edia" if show_media else "",
|
"[M]edia" if show_media else "",
|
||||||
self.tui.media_viewer,
|
|
||||||
"[R]eply",
|
"[R]eply",
|
||||||
"[P]oll" if poll and not poll["expired"] else "",
|
"[P]oll" if poll and not poll["expired"] else "",
|
||||||
"So[u]rce",
|
"So[u]rce",
|
||||||
|
@ -7,7 +7,9 @@ import unicodedata
|
|||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from typing import Dict
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
from toot.exceptions import ConsoleError
|
from toot.exceptions import ConsoleError
|
||||||
from urllib.parse import urlparse, urlencode, quote, unquote
|
from urllib.parse import urlparse, urlencode, quote, unquote
|
||||||
@ -38,7 +40,7 @@ def get_text(html):
|
|||||||
return unicodedata.normalize("NFKC", text)
|
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.
|
"""Attempt to convert html to plain text while keeping line breaks.
|
||||||
Returns a list of paragraphs, each being a list of lines.
|
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."""
|
"""Lets user input text using an editor."""
|
||||||
tmp_path = _tmp_status_path()
|
tmp_path = _tmp_status_path()
|
||||||
initial_text = (initial_text or "") + EDITOR_INPUT_INSTRUCTIONS
|
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()
|
return f.read().split(EDITOR_DIVIDER)[0].strip()
|
||||||
|
|
||||||
|
|
||||||
def read_char(values, default):
|
def delete_tmp_status_file() -> None:
|
||||||
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():
|
|
||||||
try:
|
try:
|
||||||
os.unlink(_tmp_status_path())
|
os.unlink(_tmp_status_path())
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
@ -148,50 +139,23 @@ def _tmp_status_path() -> str:
|
|||||||
return f"{tmp_dir}/.status.toot"
|
return f"{tmp_dir}/.status.toot"
|
||||||
|
|
||||||
|
|
||||||
def _use_existing_tmp_file(tmp_path) -> bool:
|
def _use_existing_tmp_file(tmp_path: str) -> bool:
|
||||||
from toot.output import print_out
|
|
||||||
|
|
||||||
if os.path.exists(tmp_path):
|
if os.path.exists(tmp_path):
|
||||||
print_out(f"<cyan>Found a draft status at: {tmp_path}</cyan>")
|
click.echo(f"Found draft status at: {tmp_path}")
|
||||||
print_out("<cyan>[O]pen (default) or [D]elete?</cyan> ", end="")
|
|
||||||
char = read_char(["o", "d"], "o")
|
choice = click.Choice(["O", "D"], case_sensitive=False)
|
||||||
return char == "o"
|
char = click.prompt("Open or Delete?", type=choice, default="O")
|
||||||
|
return char == "O"
|
||||||
|
|
||||||
return False
|
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"""
|
"""Remove keys whose values are null"""
|
||||||
return {k: v for k, v in data.items() if v is not None}
|
return {k: v for k, v in data.items() if v is not None}
|
||||||
|
|
||||||
|
|
||||||
def args_get_instance(instance, scheme, default=None):
|
def urlencode_url(url: str) -> str:
|
||||||
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):
|
|
||||||
parsed_url = urlparse(url)
|
parsed_url = urlparse(url)
|
||||||
|
|
||||||
# unencode before encoding, to prevent double-urlencoding
|
# unencode before encoding, to prevent double-urlencoding
|
||||||
|
Loading…
Reference in New Issue
Block a user