Merge branch 'master' into asyncfix
2
.flake8
@ -1,4 +1,4 @@
|
||||
[flake8]
|
||||
exclude=build,tests,tmp,venv,toot/tui/scroll.py
|
||||
ignore=E128
|
||||
ignore=E128,W503,W504
|
||||
max-line-length=120
|
||||
|
11
.github/workflows/test.yml
vendored
@ -4,12 +4,10 @@ on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
# Older Ubuntu required for testing on Python 3.6 which is not available in
|
||||
# later versions. Remove once support for 3.6 is dropped.
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.6", "3.7", "3.8", "3.9", "3.10", "3.11"]
|
||||
python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
@ -20,14 +18,13 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e .
|
||||
pip install -r requirements-test.txt
|
||||
pip install -e ".[test,richtext]"
|
||||
- name: Run tests
|
||||
run: |
|
||||
pytest
|
||||
- name: Validate minimum required version
|
||||
run: |
|
||||
vermin --target=3.6 --no-tips .
|
||||
vermin toot
|
||||
- name: Check style
|
||||
run: |
|
||||
flake8
|
||||
|
11
.gitignore
vendored
@ -6,11 +6,14 @@
|
||||
/.env
|
||||
/.envrc
|
||||
/.pytest_cache/
|
||||
/book
|
||||
/build/
|
||||
/bundle/
|
||||
/dist/
|
||||
/docs/_build/
|
||||
/htmlcov/
|
||||
/tmp/
|
||||
/toot-*.tar.gz
|
||||
debug.log
|
||||
/pyrightconfig.json
|
||||
/tmp/
|
||||
/toot-*.pyz
|
||||
/toot-*.tar.gz
|
||||
/venv/
|
||||
debug.log
|
||||
|
13
.travis.yml
@ -1,13 +0,0 @@
|
||||
language: python
|
||||
|
||||
python:
|
||||
- "3.4"
|
||||
- "3.5"
|
||||
- "3.6"
|
||||
- "3.7"
|
||||
- "nightly"
|
||||
|
||||
install:
|
||||
- pip install -e .
|
||||
|
||||
script: make test
|
4
.vermin
Normal file
@ -0,0 +1,4 @@
|
||||
[vermin]
|
||||
only_show_violations = yes
|
||||
show_tips = no
|
||||
targets = 3.7
|
131
CHANGELOG.md
@ -3,6 +3,129 @@ Changelog
|
||||
|
||||
<!-- Do not edit. This file is automatically generated from changelog.yaml.-->
|
||||
|
||||
**0.41.1 (2024-01-02)**
|
||||
|
||||
* Fix a crash in settings parsing code
|
||||
|
||||
**0.41.0 (2024-01-02)**
|
||||
|
||||
* Honour user's default visibility set in Mastodon preferences instead of always
|
||||
defaulting to public visibility (thanks Lexi Winter)
|
||||
* TUI: Add editing toots (thanks Lexi Winter)
|
||||
* TUI: Fix a bug which made palette config in settings not work
|
||||
* TUI: Show edit datetime in status detail (thanks Lexi Winter)
|
||||
|
||||
**0.40.2 (2023-12-28)**
|
||||
|
||||
* Reinstate `toot post --using` option.
|
||||
* Add shell completion for instances.
|
||||
|
||||
**0.40.1 (2023-12-28)**
|
||||
|
||||
* Add `toot --as` option to replace `toot post --using`. This now works for all
|
||||
commands.
|
||||
|
||||
**0.40.0 (2023-12-27)**
|
||||
|
||||
This release includes a rather extensive change to use the Click library
|
||||
(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. 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` and `--color` must be specified after `toot` but
|
||||
before the command
|
||||
* BREAKING: Option `--quiet` has been removed. Redirect output instead.
|
||||
* Add passing parameters 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 and lists commands
|
||||
* Add `toot --width` option for setting your preferred terminal width
|
||||
* Add `--media-viewer` and `--colors` options to `toot tui`. These were
|
||||
previously accessible only via settings.
|
||||
* TUI: Fix issue where UI did not render until first input (thanks Urwid devs)
|
||||
|
||||
**0.39.0 (2023-11-23)**
|
||||
|
||||
* Add `--json` option to many commands, this makes them print the JSON data
|
||||
returned by the server instead of human-readable data. Useful for scripting.
|
||||
* TUI: Make media viewer configurable in settings, see:
|
||||
https://toot.bezdomni.net/settings.html#tui-view-images
|
||||
* TUI: Add rich text rendering (thanks Dan Schwarz)
|
||||
|
||||
**0.38.2 (2023-11-16)**
|
||||
|
||||
* Fix compatibility with Pleroma (#399, thanks Sandra Snan)
|
||||
* Fix language documentation (thanks Sandra Snan)
|
||||
|
||||
**0.38.1 (2023-07-25)**
|
||||
|
||||
* Fix relative datetimes option in TUI
|
||||
|
||||
**0.38.0 (2023-07-25)**
|
||||
|
||||
* Add `toot muted` and `toot blocked` commands (thanks Florian Obser)
|
||||
* Add settings file, allows setting common options, defining defaults for
|
||||
command arguments, and the TUI palette
|
||||
* TUI: Remap shortcuts so they don't override HJKL used for navigation (thanks
|
||||
Dan Schwarz)
|
||||
|
||||
**0.37.0 (2023-06-28)**
|
||||
|
||||
* **BREAKING:** Require Python 3.7+
|
||||
* Add `timeline --account` option to show the account timeline (thanks Dan
|
||||
Schwarz)
|
||||
* Add `toot status` command to show a single status
|
||||
* TUI: Add personal timeline (thanks Dan Schwarz)
|
||||
* TUI: Highlight followed accounts in status details (thanks Dan Schwarz)
|
||||
* TUI: Restructured goto menu (thanks Dan Schwarz)
|
||||
* TUI: Fix boosting boosted statuses (thanks Dan Schwarz)
|
||||
* TUI: Add support for list timelines (thanks Dan Schwarz)
|
||||
|
||||
**0.36.0 (2023-03-09)**
|
||||
|
||||
* Move docs from toot.readthedocs.io to toot.bezdomni.net
|
||||
* Add specifying media thumbnails to `toot post` (#301)
|
||||
* Add creating polls to `toot post`
|
||||
* Handle custom instance domains (e.g. when server is located at
|
||||
`social.vivaldi.net`, but uses the `vivaldi.net` mastodon domain. (#217)
|
||||
* TUI: Inherit post visibility when replying (thanks @rogarb)
|
||||
* TUI: Add conversations timeline (thanks @rogarb)
|
||||
* TUI: Add shortcut to copy toot contents (thanks Dan Schwarz)
|
||||
|
||||
**0.35.0 (2023-03-01)**
|
||||
|
||||
* Save toot contents when using --editor so it's recoverable if posting fails
|
||||
(#311)
|
||||
* TUI: Add voting on polls (thanks Dan Schwarz)
|
||||
* TUI: Add following/blocking/muting accounts (thanks Dan Schwarz)
|
||||
* TUI: Add notifications timeline (thanks Dan Schwarz)
|
||||
|
||||
**0.34.1 (2023-02-20)**
|
||||
|
||||
* TUI: Fix bug where TUI would break on older Mastodon instances (#309)
|
||||
|
||||
**0.34.0 (2023-02-03)**
|
||||
|
||||
* Fix Python version detection which would fail in some cases (thanks K)
|
||||
* Fix toot --help not working (thanks Norman Walsh)
|
||||
* TUI: Add option to save status JSON data from source window (thanks Dan
|
||||
Schwarz)
|
||||
* TUI: Add `--relative-datetimes` option to show relative datetimes (thanks Dan
|
||||
Schwarz)
|
||||
* TUI: Don't focus newly created post (#188, thanks Dan Schwarz)
|
||||
* TUI: Add ability to scroll long status messages (#166, thanks Dan Schwarz)
|
||||
* TUI: Add action to view account details (thanks Dan Schwarz)
|
||||
|
||||
**0.33.1 (2023-01-03)**
|
||||
|
||||
* TUI: Fix crash when viewing toot in browser
|
||||
@ -21,7 +144,7 @@ Changelog
|
||||
* TUI: Hide polls, cards and media attachments for sensitive posts (thanks
|
||||
Daniel Schwarz)
|
||||
* TUI: Add bookmarking and bookmark timeline (thanks Daniel Schwarz)
|
||||
* TUI: Show status visiblity (thanks Lim Ding Wen)
|
||||
* TUI: Show status visibility (thanks Lim Ding Wen)
|
||||
* TUI: Reply to original account instead of boosting account (thanks Lim Ding
|
||||
Wen)
|
||||
* TUI: Refresh screen after exiting browser, required for text browsers (thanks
|
||||
@ -49,7 +172,7 @@ Changelog
|
||||
|
||||
**0.30.1 (2022-11-30)**
|
||||
|
||||
* Remove usage of depreacted `text_url` status field. Fixes posting media
|
||||
* Remove usage of deprecated `text_url` status field. Fixes posting media
|
||||
without text.
|
||||
|
||||
**0.30.0 (2022-11-29)**
|
||||
@ -102,7 +225,7 @@ Changelog
|
||||
(#168)
|
||||
* Add `--reverse` option to `toot notifications` (#151)
|
||||
* Fix `toot timeline` to respect `--instance` option
|
||||
* TUI: Add opton to pin/save tag timelines (#163, thanks @dlax)
|
||||
* TUI: Add option to pin/save tag timelines (#163, thanks @dlax)
|
||||
* TUI: Fixed crash on empty timeline (#138, thanks ecs)
|
||||
|
||||
**0.26.0 (2020-04-15)**
|
||||
@ -113,7 +236,7 @@ Changelog
|
||||
* **IMPORTANT:** Starting from this release, new releases will not be uploaded
|
||||
to the APT package repository at `bezdomni.net`. Please use the official
|
||||
Debian or Ubuntu repos or choose another [installation
|
||||
option](https://toot.readthedocs.io/en/latest/install.html).
|
||||
option](https://toot.bezdomni.net/installation.html).
|
||||
|
||||
**0.25.2 (2020-01-23)**
|
||||
|
||||
|
@ -5,7 +5,7 @@ Firstly, thank you for contributing to toot!
|
||||
|
||||
Relevant links which will be referenced below:
|
||||
|
||||
* [toot documentation](https://toot.readthedocs.io/)
|
||||
* [toot documentation](https://toot.bezdomni.net/)
|
||||
* [toot-discuss mailing list](https://lists.sr.ht/~ihabunek/toot-discuss)
|
||||
used for discussion as well as accepting patches
|
||||
* [toot project on github](https://github.com/ihabunek/toot)
|
||||
@ -77,8 +77,9 @@ pip install -r requirements-dev.txt
|
||||
pip install -r requirements-test.txt
|
||||
```
|
||||
|
||||
While the virtual env is active, running `toot` will execute the one you checked
|
||||
out. This allows you to make changes and test them.
|
||||
While the virtual env is active, you can run `./_env/bin/toot` to
|
||||
execute the one you checked out. This allows you to make changes and
|
||||
test them.
|
||||
|
||||
#### Crafting good commits
|
||||
|
||||
@ -110,7 +111,7 @@ these rules for you.
|
||||
|
||||
#### Run tests before submitting
|
||||
|
||||
You can run code and sytle tests by running:
|
||||
You can run code and style tests by running:
|
||||
|
||||
```
|
||||
make test
|
||||
|
29
Makefile
@ -10,17 +10,40 @@ publish :
|
||||
test:
|
||||
pytest -v
|
||||
flake8
|
||||
vermin --target=3.6 --no-tips --violations --exclude-regex venv/.* .
|
||||
vermin toot
|
||||
|
||||
coverage:
|
||||
coverage erase
|
||||
coverage run
|
||||
coverage html
|
||||
coverage html --omit "toot/tui/*"
|
||||
coverage report
|
||||
|
||||
clean :
|
||||
find . -name "*pyc" | xargs rm -rf $1
|
||||
rm -rf build dist MANIFEST htmlcov toot*.tar.gz
|
||||
rm -rf build dist MANIFEST htmlcov bundle toot*.tar.gz toot*.pyz
|
||||
|
||||
changelog:
|
||||
./scripts/generate_changelog > CHANGELOG.md
|
||||
cp CHANGELOG.md docs/changelog.md
|
||||
|
||||
docs: changelog
|
||||
mdbook build
|
||||
|
||||
docs-serve:
|
||||
mdbook serve --port 8000
|
||||
|
||||
docs-deploy: docs
|
||||
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"
|
||||
|
@ -6,8 +6,6 @@ Toot - a Mastodon CLI client
|
||||
|
||||
Toot is a CLI and TUI tool for interacting with Mastodon instances from the command line.
|
||||
|
||||
.. image:: https://img.shields.io/travis/ihabunek/toot.svg?maxAge=3600&style=flat-square
|
||||
:target: https://travis-ci.org/ihabunek/toot
|
||||
.. image:: https://img.shields.io/badge/author-%40ihabunek-blue.svg?maxAge=3600&style=flat-square
|
||||
:target: https://mastodon.social/@ihabunek
|
||||
.. image:: https://img.shields.io/github/license/ihabunek/toot.svg?maxAge=3600&style=flat-square
|
||||
@ -20,7 +18,7 @@ Resources
|
||||
|
||||
* Homepage: https://github.com/ihabunek/toot
|
||||
* Issues: https://github.com/ihabunek/toot/issues
|
||||
* Documentation: https://toot.readthedocs.io/en/latest/
|
||||
* Documentation: https://toot.bezdomni.net/
|
||||
* Mailing list for discussion, support and patches:
|
||||
https://lists.sr.ht/~ihabunek/toot-discuss
|
||||
* Informal discussion: #toot IRC channel on `libera.chat <https://libera.chat/>`_
|
||||
@ -39,9 +37,9 @@ Terminal User Interface
|
||||
|
||||
toot includes a terminal user interface (TUI). Run it with ``toot tui``.
|
||||
|
||||
.. image :: https://raw.githubusercontent.com/ihabunek/toot/master/docs/_static/tui_list.png
|
||||
.. image :: https://raw.githubusercontent.com/ihabunek/toot/master/docs/images/tui_list.png
|
||||
|
||||
.. image :: https://raw.githubusercontent.com/ihabunek/toot/master/docs/_static/tui_compose.png
|
||||
.. image :: https://raw.githubusercontent.com/ihabunek/toot/master/docs/images/tui_compose.png
|
||||
|
||||
|
||||
License
|
||||
|
13
book.css
Normal file
@ -0,0 +1,13 @@
|
||||
/* Overrides for the docs theme */
|
||||
table { width: 100% }
|
||||
table th { text-align: left }
|
||||
code { white-space: pre }
|
||||
h2, h3 { margin-top: 2.5rem; }
|
||||
h4, h5 { margin-top: 2rem; }
|
||||
|
||||
td.code {
|
||||
font-family: "Source Code Pro", Consolas, "Ubuntu Mono", Menlo, "DejaVu Sans Mono", monospace, monospace !important;
|
||||
font-size: 0.875em;
|
||||
width: 20%;
|
||||
white-space: nowrap;
|
||||
}
|
13
book.toml
Normal file
@ -0,0 +1,13 @@
|
||||
[book]
|
||||
authors = ["Ivan Habunek"]
|
||||
language = "en"
|
||||
multilingual = false
|
||||
src = "docs"
|
||||
title = "toot"
|
||||
|
||||
[output.html]
|
||||
additional-css = ["book.css"]
|
||||
|
||||
[preprocessor.toc]
|
||||
command = "mdbook-toc"
|
||||
renderer = ["html"]
|
127
changelog.yaml
@ -1,3 +1,122 @@
|
||||
0.41.1:
|
||||
date: 2024-01-02
|
||||
changes:
|
||||
- "Fix a crash in settings parsing code"
|
||||
|
||||
0.41.0:
|
||||
date: 2024-01-02
|
||||
changes:
|
||||
- "Honour user's default visibility set in Mastodon preferences instead of always defaulting to public visibility (thanks Lexi Winter)"
|
||||
- "TUI: Add editing toots (thanks Lexi Winter)"
|
||||
- "TUI: Fix a bug which made palette config in settings not work"
|
||||
- "TUI: Show edit datetime in status detail (thanks Lexi Winter)"
|
||||
|
||||
0.40.2:
|
||||
date: 2023-12-28
|
||||
changes:
|
||||
- "Reinstate `toot post --using` option."
|
||||
- "Add shell completion for instances."
|
||||
|
||||
0.40.1:
|
||||
date: 2023-12-28
|
||||
changes:
|
||||
- "Add `toot --as` option to replace `toot post --using`. This now works for all commands."
|
||||
|
||||
0.40.0:
|
||||
date: 2023-12-27
|
||||
description: |
|
||||
This release includes a rather extensive change to use the Click library
|
||||
(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. 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` and `--color` must be specified after `toot` but before the command"
|
||||
- "BREAKING: Option `--quiet` has been removed. Redirect output instead."
|
||||
- "Add passing parameters 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 and lists commands"
|
||||
- "Add `toot --width` option for setting your preferred terminal width"
|
||||
- "Add `--media-viewer` and `--colors` options to `toot tui`. These were previously accessible only via settings."
|
||||
- "TUI: Fix issue where UI did not render until first input (thanks Urwid devs)"
|
||||
|
||||
0.39.0:
|
||||
date: 2023-11-23
|
||||
changes:
|
||||
- "Add `--json` option to many commands, this makes them print the JSON data returned by the server instead of human-readable data. Useful for scripting."
|
||||
- "TUI: Make media viewer configurable in settings, see: https://toot.bezdomni.net/settings.html#tui-view-images"
|
||||
- "TUI: Add rich text rendering (thanks Dan Schwarz)"
|
||||
|
||||
0.38.2:
|
||||
date: 2023-11-16
|
||||
changes:
|
||||
- "Fix compatibility with Pleroma (#399, thanks Sandra Snan)"
|
||||
- "Fix language documentation (thanks Sandra Snan)"
|
||||
|
||||
0.38.1:
|
||||
date: 2023-07-25
|
||||
changes:
|
||||
- "Fix relative datetimes option in TUI"
|
||||
|
||||
0.38.0:
|
||||
date: 2023-07-25
|
||||
changes:
|
||||
- "Add `toot muted` and `toot blocked` commands (thanks Florian Obser)"
|
||||
- "Add settings file, allows setting common options, defining defaults for command arguments, and the TUI palette"
|
||||
- "TUI: Remap shortcuts so they don't override HJKL used for navigation (thanks Dan Schwarz)"
|
||||
|
||||
0.37.0:
|
||||
date: 2023-06-28
|
||||
changes:
|
||||
- "**BREAKING:** Require Python 3.7+"
|
||||
- "Add `timeline --account` option to show the account timeline (thanks Dan Schwarz)"
|
||||
- "Add `toot status` command to show a single status"
|
||||
- "TUI: Add personal timeline (thanks Dan Schwarz)"
|
||||
- "TUI: Highlight followed accounts in status details (thanks Dan Schwarz)"
|
||||
- "TUI: Restructured goto menu (thanks Dan Schwarz)"
|
||||
- "TUI: Fix boosting boosted statuses (thanks Dan Schwarz)"
|
||||
- "TUI: Add support for list timelines (thanks Dan Schwarz)"
|
||||
|
||||
0.36.0:
|
||||
date: 2023-03-09
|
||||
changes:
|
||||
- "Move docs from toot.readthedocs.io to toot.bezdomni.net"
|
||||
- "Add specifying media thumbnails to `toot post` (#301)"
|
||||
- "Add creating polls to `toot post`"
|
||||
- "Handle custom instance domains (e.g. when server is located at `social.vivaldi.net`, but uses the `vivaldi.net` mastodon domain. (#217)"
|
||||
- "TUI: Inherit post visibility when replying (thanks @rogarb)"
|
||||
- "TUI: Add conversations timeline (thanks @rogarb)"
|
||||
- "TUI: Add shortcut to copy toot contents (thanks Dan Schwarz)"
|
||||
|
||||
0.35.0:
|
||||
date: 2023-03-01
|
||||
changes:
|
||||
- "Save toot contents when using --editor so it's recoverable if posting fails (#311)"
|
||||
- "TUI: Add voting on polls (thanks Dan Schwarz)"
|
||||
- "TUI: Add following/blocking/muting accounts (thanks Dan Schwarz)"
|
||||
- "TUI: Add notifications timeline (thanks Dan Schwarz)"
|
||||
|
||||
0.34.1:
|
||||
date: 2023-02-20
|
||||
changes:
|
||||
- "TUI: Fix bug where TUI would break on older Mastodon instances (#309)"
|
||||
|
||||
0.34.0:
|
||||
date: 2023-02-03
|
||||
changes:
|
||||
- "Fix Python version detection which would fail in some cases (thanks K)"
|
||||
- "Fix toot --help not working (thanks Norman Walsh)"
|
||||
- "TUI: Add option to save status JSON data from source window (thanks Dan Schwarz)"
|
||||
- "TUI: Add `--relative-datetimes` option to show relative datetimes (thanks Dan Schwarz)"
|
||||
- "TUI: Don't focus newly created post (#188, thanks Dan Schwarz)"
|
||||
- "TUI: Add ability to scroll long status messages (#166, thanks Dan Schwarz)"
|
||||
- "TUI: Add action to view account details (thanks Dan Schwarz)"
|
||||
|
||||
0.33.1:
|
||||
date: 2023-01-03
|
||||
changes:
|
||||
@ -14,7 +133,7 @@
|
||||
- "TUI: Show an error if attemptint to boost a private status (thanks Lim Ding Wen)"
|
||||
- "TUI: Hide polls, cards and media attachments for sensitive posts (thanks Daniel Schwarz)"
|
||||
- "TUI: Add bookmarking and bookmark timeline (thanks Daniel Schwarz)"
|
||||
- "TUI: Show status visiblity (thanks Lim Ding Wen)"
|
||||
- "TUI: Show status visibility (thanks Lim Ding Wen)"
|
||||
- "TUI: Reply to original account instead of boosting account (thanks Lim Ding Wen)"
|
||||
- "TUI: Refresh screen after exiting browser, required for text browsers (thanks Daniel Schwarz)"
|
||||
- "TUI: Highlight followed tags (thanks Daniel Schwarz)"
|
||||
@ -42,7 +161,7 @@
|
||||
0.30.1:
|
||||
date: 2022-11-30
|
||||
changes:
|
||||
- "Remove usage of depreacted `text_url` status field. Fixes posting media without text."
|
||||
- "Remove usage of deprecated `text_url` status field. Fixes posting media without text."
|
||||
|
||||
0.30.0:
|
||||
date: 2022-11-29
|
||||
@ -89,7 +208,7 @@
|
||||
- "TUI: Fix access to public and tag timelines when on private mastodon instances (#168)"
|
||||
- "Add `--reverse` option to `toot notifications` (#151)"
|
||||
- "Fix `toot timeline` to respect `--instance` option"
|
||||
- "TUI: Add opton to pin/save tag timelines (#163, thanks @dlax)"
|
||||
- "TUI: Add option to pin/save tag timelines (#163, thanks @dlax)"
|
||||
- "TUI: Fixed crash on empty timeline (#138, thanks ecs)"
|
||||
|
||||
0.26.0:
|
||||
@ -98,7 +217,7 @@
|
||||
- "Fix datetime parsing on Python 3.5 (#162)"
|
||||
- "TUI: Display status links and open them (#154, thanks @dlax)"
|
||||
- "TUI: Fix visibility descriptions (#153, thanks @finnoleary)"
|
||||
- "**IMPORTANT:** Starting from this release, new releases will not be uploaded to the APT package repository at `bezdomni.net`. Please use the official Debian or Ubuntu repos or choose another [installation option](https://toot.readthedocs.io/en/latest/install.html)."
|
||||
- "**IMPORTANT:** Starting from this release, new releases will not be uploaded to the APT package repository at `bezdomni.net`. Please use the official Debian or Ubuntu repos or choose another [installation option](https://toot.bezdomni.net/installation.html)."
|
||||
|
||||
0.25.2:
|
||||
date: 2020-01-23
|
||||
|
@ -1,23 +0,0 @@
|
||||
# Minimal makefile for Sphinx documentation
|
||||
#
|
||||
|
||||
# You can set these variables from the command line.
|
||||
SPHINXOPTS =
|
||||
SPHINXBUILD = sphinx-build
|
||||
SPHINXPROJ = toot
|
||||
SOURCEDIR = .
|
||||
BUILDDIR = _build
|
||||
|
||||
# Put it first so that "make" without argument is like "make help".
|
||||
help:
|
||||
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||
|
||||
.PHONY: help Makefile
|
||||
|
||||
serve:
|
||||
sphinx-autobuild "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||
|
||||
# Catch-all target: route all unknown targets to Sphinx using the new
|
||||
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
|
||||
%: Makefile
|
||||
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
17
docs/SUMMARY.md
Normal file
@ -0,0 +1,17 @@
|
||||
# Summary
|
||||
|
||||
[Introduction](introduction.md)
|
||||
|
||||
- [Installation](installation.md)
|
||||
- [Usage](usage.md)
|
||||
- [Advanced](advanced.md)
|
||||
- [Settings](settings.md)
|
||||
- [Shell completion](shell_completion.md)
|
||||
- [Environment variables](environment_variables.md)
|
||||
- [TUI](tui.md)
|
||||
- [Contributing](contributing.md)
|
||||
- [Documentation](documentation.md)
|
||||
- [Release procedure](release.md)
|
||||
- [Changelog](changelog.md)
|
||||
|
||||
[License](license.md)
|
10
docs/_static/custom.css
vendored
@ -1,10 +0,0 @@
|
||||
pre {
|
||||
padding: 8px 15px;
|
||||
}
|
||||
|
||||
div.contents {
|
||||
background-color: inherit;
|
||||
border: 0;
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
5
docs/_templates/about.html
vendored
@ -1,5 +0,0 @@
|
||||
<h1 class="logo"><a href="{{ pathto(master_doc) }}">{{ project }}</a></h1>
|
||||
|
||||
{% if theme_description %}
|
||||
<p class="blurb">{{ theme_description }}</p>
|
||||
{% endif %}
|
@ -1,40 +1,39 @@
|
||||
==============
|
||||
Advanced usage
|
||||
==============
|
||||
|
||||
Disabling HTTPS
|
||||
---------------
|
||||
|
||||
You may pass the ``--disable-https`` flag to use unencrypted HTTP instead of
|
||||
You may pass the `--disable-https` flag to use unencrypted HTTP instead of
|
||||
HTTPS for a given instance. This is inherently insecure and should be used only
|
||||
when connecting to local development instances.
|
||||
|
||||
.. code-block:: sh
|
||||
|
||||
toot login --disable-https --instance localhost:8080
|
||||
```sh
|
||||
toot login --disable-https --instance localhost:8080
|
||||
```
|
||||
|
||||
Using proxies
|
||||
-------------
|
||||
|
||||
You can configure proxies by setting the ``HTTPS_PROXY`` or ``HTTP_PROXY``
|
||||
You can configure proxies by setting the `HTTPS_PROXY` or `HTTP_PROXY`
|
||||
environment variables. This will cause all http(s) requests to be proxied
|
||||
through the specified server.
|
||||
|
||||
For example:
|
||||
|
||||
.. code-block:: sh
|
||||
|
||||
export HTTPS_PROXY="http://1.2.3.4:5678"
|
||||
toot login --instance mastodon.social
|
||||
```sh
|
||||
export HTTPS_PROXY="http://1.2.3.4:5678"
|
||||
toot login --instance mastodon.social
|
||||
```
|
||||
|
||||
**NB:** This feature is provided by
|
||||
`requests <http://docs.python-requests.org/en/master/user/advanced/#proxies>`_
|
||||
[requests](http://docs.python-requests.org/en/master/user/advanced/#proxies>)
|
||||
and setting the environment variable will affect other programs using this
|
||||
library.
|
||||
|
||||
This environment can be set for a single call to toot by prefixing the command
|
||||
with the environment variable:
|
||||
|
||||
.. code-block:: sh
|
||||
|
||||
HTTPS_PROXY="http://1.2.3.4:5678" toot login --instance mastodon.social
|
||||
```
|
||||
HTTPS_PROXY="http://1.2.3.4:5678" toot login --instance mastodon.social
|
||||
```
|
450
docs/changelog.md
Normal file
@ -0,0 +1,450 @@
|
||||
Changelog
|
||||
---------
|
||||
|
||||
<!-- Do not edit. This file is automatically generated from changelog.yaml.-->
|
||||
|
||||
**0.41.1 (2024-01-02)**
|
||||
|
||||
* Fix a crash in settings parsing code
|
||||
|
||||
**0.41.0 (2024-01-02)**
|
||||
|
||||
* Honour user's default visibility set in Mastodon preferences instead of always
|
||||
defaulting to public visibility (thanks Lexi Winter)
|
||||
* TUI: Add editing toots (thanks Lexi Winter)
|
||||
* TUI: Fix a bug which made palette config in settings not work
|
||||
* TUI: Show edit datetime in status detail (thanks Lexi Winter)
|
||||
|
||||
**0.40.2 (2023-12-28)**
|
||||
|
||||
* Reinstate `toot post --using` option.
|
||||
* Add shell completion for instances.
|
||||
|
||||
**0.40.1 (2023-12-28)**
|
||||
|
||||
* Add `toot --as` option to replace `toot post --using`. This now works for all
|
||||
commands.
|
||||
|
||||
**0.40.0 (2023-12-27)**
|
||||
|
||||
This release includes a rather extensive change to use the Click library
|
||||
(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. 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` and `--color` must be specified after `toot` but
|
||||
before the command
|
||||
* BREAKING: Option `--quiet` has been removed. Redirect output instead.
|
||||
* Add passing parameters 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 and lists commands
|
||||
* Add `toot --width` option for setting your preferred terminal width
|
||||
* Add `--media-viewer` and `--colors` options to `toot tui`. These were
|
||||
previously accessible only via settings.
|
||||
* TUI: Fix issue where UI did not render until first input (thanks Urwid devs)
|
||||
|
||||
**0.39.0 (2023-11-23)**
|
||||
|
||||
* Add `--json` option to many commands, this makes them print the JSON data
|
||||
returned by the server instead of human-readable data. Useful for scripting.
|
||||
* TUI: Make media viewer configurable in settings, see:
|
||||
https://toot.bezdomni.net/settings.html#tui-view-images
|
||||
* TUI: Add rich text rendering (thanks Dan Schwarz)
|
||||
|
||||
**0.38.2 (2023-11-16)**
|
||||
|
||||
* Fix compatibility with Pleroma (#399, thanks Sandra Snan)
|
||||
* Fix language documentation (thanks Sandra Snan)
|
||||
|
||||
**0.38.1 (2023-07-25)**
|
||||
|
||||
* Fix relative datetimes option in TUI
|
||||
|
||||
**0.38.0 (2023-07-25)**
|
||||
|
||||
* Add `toot muted` and `toot blocked` commands (thanks Florian Obser)
|
||||
* Add settings file, allows setting common options, defining defaults for
|
||||
command arguments, and the TUI palette
|
||||
* TUI: Remap shortcuts so they don't override HJKL used for navigation (thanks
|
||||
Dan Schwarz)
|
||||
|
||||
**0.37.0 (2023-06-28)**
|
||||
|
||||
* **BREAKING:** Require Python 3.7+
|
||||
* Add `timeline --account` option to show the account timeline (thanks Dan
|
||||
Schwarz)
|
||||
* Add `toot status` command to show a single status
|
||||
* TUI: Add personal timeline (thanks Dan Schwarz)
|
||||
* TUI: Highlight followed accounts in status details (thanks Dan Schwarz)
|
||||
* TUI: Restructured goto menu (thanks Dan Schwarz)
|
||||
* TUI: Fix boosting boosted statuses (thanks Dan Schwarz)
|
||||
* TUI: Add support for list timelines (thanks Dan Schwarz)
|
||||
|
||||
**0.36.0 (2023-03-09)**
|
||||
|
||||
* Move docs from toot.readthedocs.io to toot.bezdomni.net
|
||||
* Add specifying media thumbnails to `toot post` (#301)
|
||||
* Add creating polls to `toot post`
|
||||
* Handle custom instance domains (e.g. when server is located at
|
||||
`social.vivaldi.net`, but uses the `vivaldi.net` mastodon domain. (#217)
|
||||
* TUI: Inherit post visibility when replying (thanks @rogarb)
|
||||
* TUI: Add conversations timeline (thanks @rogarb)
|
||||
* TUI: Add shortcut to copy toot contents (thanks Dan Schwarz)
|
||||
|
||||
**0.35.0 (2023-03-01)**
|
||||
|
||||
* Save toot contents when using --editor so it's recoverable if posting fails
|
||||
(#311)
|
||||
* TUI: Add voting on polls (thanks Dan Schwarz)
|
||||
* TUI: Add following/blocking/muting accounts (thanks Dan Schwarz)
|
||||
* TUI: Add notifications timeline (thanks Dan Schwarz)
|
||||
|
||||
**0.34.1 (2023-02-20)**
|
||||
|
||||
* TUI: Fix bug where TUI would break on older Mastodon instances (#309)
|
||||
|
||||
**0.34.0 (2023-02-03)**
|
||||
|
||||
* Fix Python version detection which would fail in some cases (thanks K)
|
||||
* Fix toot --help not working (thanks Norman Walsh)
|
||||
* TUI: Add option to save status JSON data from source window (thanks Dan
|
||||
Schwarz)
|
||||
* TUI: Add `--relative-datetimes` option to show relative datetimes (thanks Dan
|
||||
Schwarz)
|
||||
* TUI: Don't focus newly created post (#188, thanks Dan Schwarz)
|
||||
* TUI: Add ability to scroll long status messages (#166, thanks Dan Schwarz)
|
||||
* TUI: Add action to view account details (thanks Dan Schwarz)
|
||||
|
||||
**0.33.1 (2023-01-03)**
|
||||
|
||||
* TUI: Fix crash when viewing toot in browser
|
||||
|
||||
**0.33.0 (2023-01-02)**
|
||||
|
||||
* Add CONTRIBUTING.md containing a contribution guide
|
||||
* Add `env` command which prints local env to include in issues
|
||||
* Add TOOT_POST_VISIBILITY environment to control default post visibility
|
||||
(thanks Lim Ding Wen)
|
||||
* Add `tags_followed`, `tags_follow`, and `tags_unfollow` commands (thanks
|
||||
Daniel Schwarz)
|
||||
* Add `tags_bookmarks` command (thanks Giuseppe Bilotta)
|
||||
* TUI: Show an error if attemptint to boost a private status (thanks Lim Ding
|
||||
Wen)
|
||||
* TUI: Hide polls, cards and media attachments for sensitive posts (thanks
|
||||
Daniel Schwarz)
|
||||
* TUI: Add bookmarking and bookmark timeline (thanks Daniel Schwarz)
|
||||
* TUI: Show status visibility (thanks Lim Ding Wen)
|
||||
* TUI: Reply to original account instead of boosting account (thanks Lim Ding
|
||||
Wen)
|
||||
* TUI: Refresh screen after exiting browser, required for text browsers (thanks
|
||||
Daniel Schwarz)
|
||||
* TUI: Highlight followed tags (thanks Daniel Schwarz)
|
||||
|
||||
**0.32.1 (2022-12-12)**
|
||||
|
||||
* Fix packaging issue, missing toot.utils module
|
||||
|
||||
**0.32.0 (2022-12-12)**
|
||||
|
||||
* TUI: Press N to translate status, if available on your instance (thanks Daniel
|
||||
Schwarz)
|
||||
* Fix: `post --language` option now accepts two-letter country code instead of
|
||||
3-letter. This was changed by mastodon at some point.
|
||||
* Fix: Failing to find accounts using qualified usernames (#254)
|
||||
|
||||
**0.31.0 (2022-12-07)**
|
||||
|
||||
* **BREAKING:** Require Python 3.6+
|
||||
* Add `post --scheduled-in` option for easier scheduling
|
||||
* Fix posting toots to Pleroma
|
||||
* Improved testing
|
||||
|
||||
**0.30.1 (2022-11-30)**
|
||||
|
||||
* Remove usage of deprecated `text_url` status field. Fixes posting media
|
||||
without text.
|
||||
|
||||
**0.30.0 (2022-11-29)**
|
||||
|
||||
* Display polls in `timeline` (thanks Daniel Schwarz)
|
||||
* TUI: Add [,] shortcut to reload timeline (thanks Daniel Schwarz)
|
||||
* TUI: Add [Z] shortcut to zoom status - allows scrolling (thanks
|
||||
@PeterFidelman)
|
||||
* Internals: add integration tests against a local mastodon instance
|
||||
|
||||
**0.29.0 (2022-11-21)**
|
||||
|
||||
* Add `bookmark` and `unbookmark` commands
|
||||
* Add `following` and `followers` commands (thanks @Oblomov)
|
||||
* TUI: Show media attachments in links list (thanks @PeterFidelman)
|
||||
* Fix tests so that they don't depend on the local timezone
|
||||
|
||||
**0.28.1 (2022-11-12)**
|
||||
|
||||
* Fix account search to be case insensitive (thanks @TheJokersThief)
|
||||
* Fix account search to use v2 endpoint, since v1 endpoint was removed on some
|
||||
instances (thanks @kaja47)
|
||||
* Add '.toot' extension to temporary files when composing toot in an editor
|
||||
(thanks @larsks)
|
||||
* Display localized datetimes in timeline (thanks @mmmmmmbeer)
|
||||
* Don't use # for comments when composing toot in an editor, since that made it
|
||||
impossible to post lines starting with #.
|
||||
* TUI: Fix crash when poll does not have an expiry date
|
||||
|
||||
**0.28.0 (2021-08-28)**
|
||||
|
||||
* **BREAKING**: Removed `toot curses`, deprecated since 2019-09-03
|
||||
* Add `--scheduled-at` option to `toot post`, allows scheduling toots
|
||||
* Add `--description` option to `toot post`, for adding descriptions to media
|
||||
attachments (thanks @ansuz)
|
||||
* Add `--mentions` option to `toot notifications` to show only mentions (thanks
|
||||
@alexwennerberg)
|
||||
* Add `--content-type` option to `toot post` to allow specifying mime type, used
|
||||
on Pleroma (thanks Sandra Snan)
|
||||
* Allow post IDs to be strings as used on Pleroma (thanks Sandra Snan)
|
||||
* TUI: Allow posts longer than 500 characters if so configured on the server
|
||||
(thanks Sandra Snan)
|
||||
* Allow piping the password to login_cli for testing purposes (thanks
|
||||
@NinjaTrappeur)
|
||||
* Disable paging timeline when output is piped (thanks @stacyharper)
|
||||
|
||||
**0.27.0 (2020-06-15)**
|
||||
|
||||
* TUI: Fix access to public and tag timelines when on private mastodon instances
|
||||
(#168)
|
||||
* Add `--reverse` option to `toot notifications` (#151)
|
||||
* Fix `toot timeline` to respect `--instance` option
|
||||
* TUI: Add option to pin/save tag timelines (#163, thanks @dlax)
|
||||
* TUI: Fixed crash on empty timeline (#138, thanks ecs)
|
||||
|
||||
**0.26.0 (2020-04-15)**
|
||||
|
||||
* Fix datetime parsing on Python 3.5 (#162)
|
||||
* TUI: Display status links and open them (#154, thanks @dlax)
|
||||
* TUI: Fix visibility descriptions (#153, thanks @finnoleary)
|
||||
* **IMPORTANT:** Starting from this release, new releases will not be uploaded
|
||||
to the APT package repository at `bezdomni.net`. Please use the official
|
||||
Debian or Ubuntu repos or choose another [installation
|
||||
option](https://toot.bezdomni.net/installation.html).
|
||||
|
||||
**0.25.2 (2020-01-23)**
|
||||
|
||||
* Revert adding changelog and readme to sourceballs (#149)
|
||||
* TUI: Fall back to username when display_name is unset (thanks @dlax)
|
||||
* Note: 0.25.1 was skipped due to error when releasing
|
||||
|
||||
**0.25.0 (2020-01-21)**
|
||||
|
||||
* TUI: Show character count when composing (#121)
|
||||
* Include changelog and license in sourceballs (#133)
|
||||
* Fix searching by hashtag which include the '#' (#134)
|
||||
* Upgrade search to v2 (#135)
|
||||
* Fix compatibility with Python < 3.6 (don't use fstrings)
|
||||
|
||||
**0.24.0 (2019-09-18)**
|
||||
|
||||
* On Windows store config files under %APPDATA%
|
||||
* CLI: Don't use ANSI colors if not supported by terminal or when not in a tty
|
||||
* TUI: Implement deleting own status messages
|
||||
* TUI: Improve rendering of reblogged statuses (thanks @dlax)
|
||||
* TUI: Set urwid encoding to UTF-8 (thanks @bearzk)
|
||||
|
||||
**0.23.1 (2019-09-04)**
|
||||
|
||||
* Fix a date parsing bug in Python versions <3.7 (#114)
|
||||
|
||||
**0.23.0 (2019-09-03)**
|
||||
|
||||
* Add `toot tui`, new and improved TUI implemented written with the help of the
|
||||
[urwid](http://urwid.org/) library
|
||||
* Deprecate `toot curses`. It will show a deprecation notice when started. To be
|
||||
removed in a future release
|
||||
* Add `--editor` option to `toot post` to allow composing toots in an editor
|
||||
(#90)
|
||||
* Fix config file permissions, set them to 0600 when creating the initial config
|
||||
file (#109)
|
||||
* Add user agent string to all requests, fixes interaction with instances
|
||||
protected by Cloudflare (#106)
|
||||
|
||||
**0.22.0 (2019-08-01)**
|
||||
|
||||
* **BREAKING:** Dropped support for Python 3.3
|
||||
* Add `toot notifications` to show notifications (thanks @dlax)
|
||||
* Add posting and replying to curses interface (thanks @Skehmatics)
|
||||
* Add `--language` option to `toot post`
|
||||
* Enable attaching upto 4 files via `--media` option on `toot post`
|
||||
|
||||
**0.21.0 (2019-02-15)**
|
||||
|
||||
* **BREAKING:** in `toot timeline` short argument for selecting a list is no
|
||||
longer `-i`, this has been changed to select the instance, so that it is the
|
||||
same as on other commands, please use the long form `--list` instead
|
||||
* Add `toot reblogged_by` to show who reblogged a status (#88)
|
||||
* Add `toot thread` to show a status with its replies (#87)
|
||||
* Better handling of wide characters (eastern scripts, emojis) (#84)
|
||||
* Improved `timeline`, nicer visuals, and it will now ask to show next batch of
|
||||
toots, unless given the `--once` option
|
||||
* Add public/local/tag timelines to `timeline` and `curses`
|
||||
* Support for boosting and favouriting in `toot curses`, press `f`/`b` (#88,
|
||||
#93)
|
||||
|
||||
**0.20.0 (2019-02-01)**
|
||||
|
||||
* Enable interaction with instances using http instead of https (#56)
|
||||
* Enable proxy usage via environment variables (#47)
|
||||
* Make `toot post` prompt for input if no text is given (#82)
|
||||
* Add post-related commands: `favourite`, `unfavourite`, `reblog`, `unreblog`,
|
||||
`pin` & `unpin` (#75)
|
||||
|
||||
**0.19.0 (2018-06-27)**
|
||||
|
||||
* Add support for replying to a toot (#6)
|
||||
* Add `toot delete` command for deleting a toot (#54)
|
||||
* Add global `--quiet` flag to silence output (#46)
|
||||
* Make `toot login` provide browser login, and `toot login_cli` log in via
|
||||
console. This makes it clear what's the preferred option.
|
||||
* Use Idempotency-Key header to prevent multiple toots being posted if request
|
||||
is retried
|
||||
* Fix a bug where all media would be marked as sensitive
|
||||
|
||||
**0.18.0 (2018-06-12)**
|
||||
|
||||
* Add support for public, tag and list timelines in `toot timeline` (#52)
|
||||
* Add `--sensitive` and `--spoiler-text` options to `toot post` (#63)
|
||||
* Curses app improvements (respect sensitive content, require keypress to show,
|
||||
add help modal, misc improvements)
|
||||
|
||||
**0.17.1 (2018-01-15)**
|
||||
|
||||
* Create config folder if it does not exist (#40)
|
||||
* Fix packaging to include `toot.ui` package (#41)
|
||||
|
||||
**0.17.0 (2018-01-15)**
|
||||
|
||||
* Changed configuration file format to allow switching between multiple logged
|
||||
in accounts (#32)
|
||||
* Respect XDG_CONFIG_HOME environment variable to locate config home (#12)
|
||||
* Dynamically calculate left window width, supports narrower windows (#27)
|
||||
* Redraw windows when terminal size changes (#25)
|
||||
* Support scrolling the status list
|
||||
* Fetch next batch of statuses when bottom is reached
|
||||
* Support up/down arrows (#30)
|
||||
* Misc visual improvements
|
||||
|
||||
**0.16.2 (2018-01-02)**
|
||||
|
||||
* No changes, pushed to fix a packaging issue
|
||||
|
||||
**0.16.1 (2017-12-30)**
|
||||
|
||||
* Fix bug with app registration
|
||||
|
||||
**0.16.0 (2017-12-30)**
|
||||
|
||||
* **BREAKING:** Dropped support for Python 2, because it's a pain to support and
|
||||
caused bugs with handling unicode.
|
||||
* Remove hacky `login_2fa` command, use `login_browser` instead
|
||||
* Add `instance` command
|
||||
* Allow `post`ing media without text (#24)
|
||||
|
||||
**0.15.1 (2017-12-12)**
|
||||
|
||||
* Fix crash when toot's URL is None (#33), thanks @veer66
|
||||
|
||||
**0.15.0 (2017-09-09)**
|
||||
|
||||
* Fix Windows compatibility (#18)
|
||||
|
||||
**0.14.0 (2017-09-07)**
|
||||
|
||||
* Add `--debug` option to enable debug logging instead of using the `TOOT_DEBUG`
|
||||
environment variable.
|
||||
* Fix: don't read requirements.txt from setup.py, this fails when packaging deb
|
||||
and potentially in some other cases (see #18)
|
||||
|
||||
**0.13.0 (2017-08-26)**
|
||||
|
||||
* Allow passing `--instance` and `--email` to login command
|
||||
* Add `login_browser` command for proper two factor authentication through the
|
||||
browser (#19, #23)
|
||||
|
||||
**0.12.0 (2017-05-08)**
|
||||
|
||||
* Add option to disable ANSI color in output (#15)
|
||||
* Return nonzero error code on error (#14)
|
||||
* Change license to GPLv3
|
||||
|
||||
**0.11.0 (2017-05-07)**
|
||||
|
||||
* Fix error when running toot from crontab (#11)
|
||||
* Minor tweaks
|
||||
|
||||
**0.10.0 (2017-04-26)**
|
||||
|
||||
* Add commands: `block`, `unblock`, `mute`, `unmute`
|
||||
* Internal improvements
|
||||
|
||||
**0.9.1 (2017-04-24)**
|
||||
|
||||
* Fix conflict with curses package name
|
||||
|
||||
**0.9.0 (2017-04-21)**
|
||||
|
||||
* Add `whois` command
|
||||
* Add experimental `curses` app for viewing the timeline
|
||||
|
||||
**0.8.0 (2017-04-19)**
|
||||
|
||||
* **BREAKING:** Renamed command `2fa` to `login_2fa`
|
||||
* It is now possible to pipe text into `toot post`
|
||||
|
||||
**0.7.0 (2017-04-18)**
|
||||
|
||||
* **WARNING:** Due to changes in configuration format, after upgrading to this
|
||||
version, you will be required to log in to your Mastodon instance again.
|
||||
* Experimental 2FA support (#3)
|
||||
* Do not create a new application for each login
|
||||
|
||||
**0.6.0 (2017-04-17)**
|
||||
|
||||
* Add `whoami` command
|
||||
* Migrate from `optparse` to `argparse`
|
||||
|
||||
**0.5.0 (2017-04-16)**
|
||||
|
||||
* Add `search`, `follow` and `unfollow` commands
|
||||
* Migrate from `optparse` to `argparse`
|
||||
|
||||
**0.4.0 (2017-04-15)**
|
||||
|
||||
* Add `upload` command to post media
|
||||
* Add `--visibility` and `--media` options to `post` command
|
||||
|
||||
**0.3.0 (2017-04-13)**
|
||||
|
||||
* Add: view timeline
|
||||
* Require an explicit login
|
||||
|
||||
**0.2.1 (2017-04-13)**
|
||||
|
||||
* Fix invalid requirements in setup.py
|
||||
|
||||
**0.2.0 (2017-04-12)**
|
||||
|
||||
* Bugfixes
|
||||
|
||||
**0.1.0 (2017-04-12)**
|
||||
|
||||
* Initial release
|
||||
|
38
docs/conf.py
@ -1,38 +0,0 @@
|
||||
from datetime import datetime
|
||||
|
||||
# -- Project information -----------------------------------------------------
|
||||
|
||||
project = 'toot'
|
||||
year = datetime.now().year
|
||||
copyright = '{}, Ivan Habunek'.format(year)
|
||||
author = 'Ivan Habunek'
|
||||
|
||||
# -- General configuration ---------------------------------------------------
|
||||
|
||||
extensions = []
|
||||
templates_path = ['_templates']
|
||||
source_suffix = '.rst'
|
||||
master_doc = 'index'
|
||||
exclude_patterns = ['_build']
|
||||
pygments_style = 'sphinx'
|
||||
|
||||
# -- Options for HTML output -------------------------------------------------
|
||||
|
||||
html_theme = 'alabaster'
|
||||
html_theme_options = {
|
||||
"description": "Mastodon CLI client",
|
||||
"github_user": "ihabunek",
|
||||
"github_repo": "toot",
|
||||
"fixed_sidebar": True,
|
||||
"travis_button": True,
|
||||
"logo": 'trumpet.png',
|
||||
}
|
||||
html_static_path = ['_static']
|
||||
html_sidebars = {
|
||||
"**": [
|
||||
"about.html",
|
||||
"navigation.html",
|
||||
"relations.html",
|
||||
"searchbox.html",
|
||||
]
|
||||
}
|
148
docs/contributing.md
Normal file
@ -0,0 +1,148 @@
|
||||
Toot contribution guide
|
||||
=======================
|
||||
|
||||
Firstly, thank you for contributing to toot!
|
||||
|
||||
Relevant links which will be referenced below:
|
||||
|
||||
* [toot documentation](https://toot.bezdomni.net/)
|
||||
* [toot-discuss mailing list](https://lists.sr.ht/~ihabunek/toot-discuss)
|
||||
used for discussion as well as accepting patches
|
||||
* [toot project on github](https://github.com/ihabunek/toot)
|
||||
here you can report issues and submit pull requests
|
||||
* #toot IRC channel on [libera.chat](https://libera.chat)
|
||||
|
||||
## Code of conduct
|
||||
|
||||
Please be kind and patient. Toot is maintained by one human with a full time
|
||||
job.
|
||||
|
||||
## I have a question
|
||||
|
||||
First, check if your question is addressed in the documentation or the mailing
|
||||
list. If not, feel free to send an email to the mailing list. You may want to
|
||||
subscribe to the mailing list to receive replies.
|
||||
|
||||
Alternatively, you can ask your question on the IRC channel and ping me
|
||||
(ihabunek). You may have to wait for a response, please be patient.
|
||||
|
||||
Please don't open Github issues for questions.
|
||||
|
||||
## I want to contribute
|
||||
|
||||
### Reporting a bug
|
||||
|
||||
First check you're using the
|
||||
[latest version](https://github.com/ihabunek/toot/releases/) of toot and verify
|
||||
the bug is present in this version.
|
||||
|
||||
Search [Github issues](https://github.com/ihabunek/toot/issues) to check the bug
|
||||
hasn't already been reported.
|
||||
|
||||
To report a bug open an
|
||||
[issue on Github](https://github.com/ihabunek/toot/issues) or send an
|
||||
email to the [mailing list](https://lists.sr.ht/~ihabunek/toot-discuss).
|
||||
|
||||
* Run `toot env` and include its contents in the bug report.
|
||||
* Explain the behavior you would expect and the actual behavior.
|
||||
* Please provide as much context as possible and describe the reproduction steps
|
||||
that someone else can follow to recreate the issue on their own.
|
||||
|
||||
### Suggesting enhancements
|
||||
|
||||
This includes suggesting new features or changes to existing ones.
|
||||
|
||||
Search Github issues to check the enhancement has not already been requested. If
|
||||
it hasn't, [open a new issue](https://github.com/ihabunek/toot/issues).
|
||||
|
||||
Your request will be reviewed to see if it's a good fit for toot. Implementing
|
||||
requested features depends on the available time and energy of the maintainer
|
||||
and other contributors.
|
||||
|
||||
### Contributing code
|
||||
|
||||
When contributing to toot, please only submit code that you have authored or
|
||||
code whose license allows it to be included in toot. You agree that the code
|
||||
you submit will be published under the [toot license](LICENSE).
|
||||
|
||||
#### Setting up a dev environment
|
||||
|
||||
Check out toot (or a fork) and install it into a virtual environment.
|
||||
|
||||
```bash
|
||||
git clone git@github.com:ihabunek/toot.git
|
||||
cd toot
|
||||
python3 -m venv _env
|
||||
|
||||
# On Linux/Mac
|
||||
source _env/bin/activate
|
||||
|
||||
# On Windows
|
||||
_env\bin\activate.bat
|
||||
|
||||
pip install --editable ".[dev,test]"
|
||||
```
|
||||
|
||||
While the virtual env is active, running `toot` will execute the one you checked
|
||||
out. This allows you to make changes and test them.
|
||||
|
||||
#### Crafting good commits
|
||||
|
||||
Please put some effort into breaking your contribution up into a series of well
|
||||
formed commits. If you're unsure what this means, there is a good guide
|
||||
available at [https://cbea.ms/git-commit/](https://cbea.ms/git-commit/).
|
||||
|
||||
Rules for commits:
|
||||
|
||||
* each commit should ideally contain only one change
|
||||
* don't bundle multiple unrelated changes into a single commit
|
||||
* write descriptive and well formatted commit messages
|
||||
|
||||
Rules for commit messages:
|
||||
|
||||
* separate subject from body with a blank line
|
||||
* limit the subject line to 50 characters
|
||||
* capitalize the subject line
|
||||
* do not end the subject line with a period
|
||||
* use the imperative mood in the subject line
|
||||
* wrap the body at 72 characters
|
||||
* use the body to explain what and why vs. how
|
||||
|
||||
For a more detailed explanation with examples see the guide at
|
||||
[https://cbea.ms/git-commit/](https://cbea.ms/git-commit/)
|
||||
|
||||
If you use vim to write your commit messages, it will already enforce some of
|
||||
these rules for you.
|
||||
|
||||
#### Run tests before submitting
|
||||
|
||||
You can run code and style tests by running:
|
||||
|
||||
```
|
||||
make test
|
||||
```
|
||||
|
||||
This runs three tools:
|
||||
|
||||
* `pytest` runs the test suite
|
||||
* `flake8` checks code formatting
|
||||
* `vermin` checks that minimum python version
|
||||
|
||||
Please ensure all three commands succeed before submitting your patches.
|
||||
|
||||
#### Submitting patches
|
||||
|
||||
To submit your code either open
|
||||
[a pull request](https://github.com/ihabunek/toot/pulls) on Github, or send
|
||||
patch(es) to [the mailing list](https://lists.sr.ht/~ihabunek/toot-discuss).
|
||||
|
||||
If sending to the mailing list, patches should be sent using `git send-email`.
|
||||
If you're unsure how to do this, there is a good guide at
|
||||
[https://git-send-email.io/](https://git-send-email.io/).
|
||||
|
||||
---
|
||||
|
||||
Parts of this guide were taken from the following sources:
|
||||
|
||||
* [https://contributing.md/](https://contributing.md/)
|
||||
* [https://cbea.ms/git-commit/](https://cbea.ms/git-commit/)
|
38
docs/documentation.md
Normal file
@ -0,0 +1,38 @@
|
||||
Documentation
|
||||
=============
|
||||
|
||||
Documentation is generated using [mdBook](https://rust-lang.github.io/mdBook/).
|
||||
|
||||
Documentation is written in markdown and located in the `docs` directory.
|
||||
|
||||
Additional plugins:
|
||||
|
||||
- [mdbook-toc](https://github.com/badboy/mdbook-toc)
|
||||
|
||||
Install prerequisites
|
||||
---------------------
|
||||
|
||||
You'll need a moderately recent version of Rust (1.60) at the time of writing.
|
||||
Check out [mdbook installation docs](https://rust-lang.github.io/mdBook/guide/installation.html)
|
||||
for details.
|
||||
|
||||
Install by building from source:
|
||||
|
||||
```
|
||||
cargo install mdbook mdbook-toc
|
||||
```
|
||||
|
||||
Generate
|
||||
--------
|
||||
|
||||
HTML documentation is generated from sources by running:
|
||||
|
||||
```
|
||||
mdbook build
|
||||
```
|
||||
|
||||
To run a local server which will rebuild on change:
|
||||
|
||||
```
|
||||
mdbook serve
|
||||
```
|
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).
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
Before Width: | Height: | Size: 192 KiB After Width: | Height: | Size: 192 KiB |
Before Width: | Height: | Size: 209 KiB After Width: | Height: | Size: 209 KiB |
@ -1,72 +0,0 @@
|
||||
toot - Mastodon CLI client
|
||||
==========================
|
||||
|
||||
.. image:: _static/trumpet.png
|
||||
|
||||
Toot is a CLI and TUI tool for interacting with Mastodon instances from the command line.
|
||||
|
||||
.. image:: https://img.shields.io/travis/ihabunek/toot.svg?maxAge=3600&style=flat-square
|
||||
:target: https://travis-ci.org/ihabunek/toot
|
||||
.. image:: https://img.shields.io/badge/author-%40ihabunek-blue.svg?maxAge=3600&style=flat-square
|
||||
:target: https://mastodon.social/@ihabunek
|
||||
.. image:: https://img.shields.io/github/license/ihabunek/toot.svg?maxAge=3600&style=flat-square
|
||||
:target: https://opensource.org/licenses/GPL-3.0
|
||||
.. image:: https://img.shields.io/pypi/v/toot.svg?maxAge=3600&style=flat-square
|
||||
:target: https://pypi.python.org/pypi/toot
|
||||
|
||||
Resources
|
||||
---------
|
||||
|
||||
* Homepage: https://github.com/ihabunek/toot
|
||||
* Issues: https://github.com/ihabunek/toot/issues
|
||||
* Documentation: https://toot.readthedocs.io/en/latest/
|
||||
* Mailing list for discussion, support and patches:
|
||||
https://lists.sr.ht/~ihabunek/toot-discuss
|
||||
* Informal discussion: #toot IRC channel on `libera.chat <https://libera.chat/>`_
|
||||
|
||||
Features
|
||||
--------
|
||||
|
||||
* Posting, replying, deleting, favouriting, reblogging & pinning statuses
|
||||
* Support for media uploads, spoiler text, sensitive content
|
||||
* Search by account or hash tag
|
||||
* Following, muting and blocking accounts
|
||||
* Simple switching between multiple Mastodon accounts
|
||||
|
||||
Contents
|
||||
--------
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
||||
install
|
||||
usage
|
||||
advanced
|
||||
release
|
||||
|
||||
Curses UI
|
||||
---------
|
||||
|
||||
toot includes a curses-based terminal user interface (TUI). Run it with ``toot tui``.
|
||||
|
||||
.. image :: _static/tui_list.png
|
||||
|
||||
.. image :: _static/tui_poll.png
|
||||
|
||||
.. image :: _static/tui_compose.png
|
||||
|
||||
Development
|
||||
-----------
|
||||
|
||||
The project source code and issue tracker are available on GitHub:
|
||||
|
||||
https://github.com/ihabunek/toot
|
||||
|
||||
Please report any issues there. Pull requests are welcome.
|
||||
|
||||
License
|
||||
-------
|
||||
|
||||
Copyright Ivan Habunek <ivan@habunek.com> and contributors.
|
||||
|
||||
Licensed under `GPLv3 <http://www.gnu.org/licenses/gpl-3.0.html>`_.
|
123
docs/install.rst
@ -1,123 +0,0 @@
|
||||
============
|
||||
Installation
|
||||
============
|
||||
|
||||
toot is packaged for various platforms.
|
||||
|
||||
.. contents::
|
||||
:local:
|
||||
:backlinks: none
|
||||
|
||||
Overview
|
||||
--------
|
||||
|
||||
Packaging overview provided by `repology.org <https://repology.org/project/toot/versions>`_.
|
||||
|
||||
.. image :: https://repology.org/badge/vertical-allrepos/toot.svg
|
||||
:alt: Packaging status
|
||||
:target: https://repology.org/project/toot/versions
|
||||
|
||||
Debian & Ubuntu
|
||||
---------------
|
||||
|
||||
Since Debian 10 (buster) and Ubuntu 19.04 (disco), toot is available in the
|
||||
official package repository.
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
sudo apt install toot
|
||||
|
||||
Debian package is maintained by `Jonathan Carter <https://mastodon.xyz/@highvoltage>`_.
|
||||
|
||||
|
||||
Arch Linux
|
||||
----------
|
||||
|
||||
Install from `AUR <https://aur.archlinux.org/packages/toot/>`_.
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
yay -S toot
|
||||
|
||||
|
||||
Fedora
|
||||
-------------
|
||||
|
||||
Toot is available from the Fedora package repository.
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
sudo dnf install toot
|
||||
|
||||
|
||||
FreeBSD ports
|
||||
-------------
|
||||
|
||||
Install the package:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
pkg install py38-toot
|
||||
|
||||
Build and install from sources:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
cd /usr/ports/net-im/toot
|
||||
make install
|
||||
|
||||
FreeBSD port is maintained by `Mateusz Piotrowski <https://mastodon.social/@mpts>`_
|
||||
|
||||
Nixpkgs
|
||||
-------
|
||||
|
||||
This works on NixOS or systems with the Nix package manager installed.
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
nix-env -iA nixos.toot
|
||||
|
||||
|
||||
OpenBSD ports
|
||||
-------------
|
||||
|
||||
Install the package:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
pkg_add toot
|
||||
|
||||
Build and install from sources:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
cd /usr/ports/net/toot
|
||||
make install
|
||||
|
||||
OpenBSD port is maintained by `Klemens Nanni <mailto:kl3@posteo.org>`_
|
||||
|
||||
Python Package Index
|
||||
--------------------
|
||||
|
||||
Install from PyPI using pip, preferably into a virtual environment.
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
pip install --user toot
|
||||
|
||||
Homebrew
|
||||
--------------------
|
||||
|
||||
This works on Mac OSX with `homebrew <https://brew.sh/>`_ installed.
|
||||
Tested with on Catalina, Mojave, and High Sierra.
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
brew update
|
||||
brew install toot
|
||||
|
||||
Source
|
||||
------
|
||||
|
||||
Finally, you can get the latest source distribution, wheel or debian package
|
||||
`from GitHub <https://github.com/ihabunek/toot/releases/latest/>`_.
|
22
docs/installation.md
Normal file
@ -0,0 +1,22 @@
|
||||
Installation
|
||||
============
|
||||
|
||||
toot is packaged for various platforms. If possible use your OS's package manager to install toot.
|
||||
|
||||
[![Packaging status](https://repology.org/badge/vertical-allrepos/toot.svg)](https://repology.org/project/toot/versions)
|
||||
|
||||
## Python Package Index
|
||||
|
||||
Install from PyPI using pip, preferably into a virtual environment.
|
||||
|
||||
pip install toot
|
||||
|
||||
## Homebrew
|
||||
|
||||
For Mac OSX users, toot is available [in homebrew](https://formulae.brew.sh/formula/toot#default).
|
||||
|
||||
brew install toot
|
||||
|
||||
## From source
|
||||
|
||||
You can get the latest source distribution [from Github](https://github.com/ihabunek/toot/releases/latest/).
|
46
docs/introduction.md
Normal file
@ -0,0 +1,46 @@
|
||||
toot - Mastodon CLI client
|
||||
==========================
|
||||
|
||||
![Toot trumpet logo](./trumpet.png)
|
||||
|
||||
Toot is a CLI and TUI tool for interacting with Mastodon (and other compatible) instances from the command line.
|
||||
|
||||
[![](https://img.shields.io/badge/author-%40ihabunek-blue.svg?maxAge=3600&style=flat-square)](https://mastodon.social/@ihabunek)
|
||||
[![](https://img.shields.io/github/license/ihabunek/toot.svg?maxAge=3600&style=flat-square)](https://opensource.org/licenses/GPL-3.0)
|
||||
[![](https://img.shields.io/pypi/v/toot.svg?maxAge=3600&style=flat-square)](https://pypi.python.org/pypi/toot)
|
||||
|
||||
Resources
|
||||
---------
|
||||
|
||||
* [Documentation](https://toot.bezdomni.net/)
|
||||
* [Source code on GitHub](https://github.com/ihabunek/toot)
|
||||
* [Issues on GitHub](https://github.com/ihabunek/toot/issues)
|
||||
* [Mailing list on Sourcehut](https://lists.sr.ht/~ihabunek/toot-discuss) for discussion, support and patches
|
||||
* Informal discussion on the #toot IRC channel on [libera.chat](https://libera.chat/)
|
||||
|
||||
Command line client
|
||||
-------------------
|
||||
|
||||
* Posting, replying, deleting, favouriting, reblogging & pinning statuses
|
||||
* Support for media uploads, spoiler text, sensitive content
|
||||
* Search by account or hash tag
|
||||
* Following, muting and blocking accounts
|
||||
* Simple switching between multiple Mastodon accounts
|
||||
|
||||
Terminal User Interface
|
||||
-----------------------
|
||||
|
||||
toot includes a terminal user interface. Run it with `toot tui`.
|
||||
|
||||
![](images/tui_list.png)
|
||||
|
||||
![](images/tui_poll.png)
|
||||
|
||||
![](images/tui_compose.png)
|
||||
|
||||
License
|
||||
-------
|
||||
|
||||
Copyright Ivan Habunek <ivan@habunek.com> and contributors.
|
||||
|
||||
Licensed under the [GPLv3](http://www.gnu.org/licenses/gpl-3.0.html) license.
|
675
docs/license.md
Normal file
@ -0,0 +1,675 @@
|
||||
### GNU GENERAL PUBLIC LICENSE
|
||||
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc.
|
||||
<https://fsf.org/>
|
||||
|
||||
Everyone is permitted to copy and distribute verbatim copies of this
|
||||
license document, but changing it is not allowed.
|
||||
|
||||
### Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom
|
||||
to share and change all versions of a program--to make sure it remains
|
||||
free software for all its users. We, the Free Software Foundation, use
|
||||
the GNU General Public License for most of our software; it applies
|
||||
also to any other work released this way by its authors. You can apply
|
||||
it to your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you
|
||||
have certain responsibilities if you distribute copies of the
|
||||
software, or if you modify it: responsibilities to respect the freedom
|
||||
of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the
|
||||
manufacturer can do so. This is fundamentally incompatible with the
|
||||
aim of protecting users' freedom to change the software. The
|
||||
systematic pattern of such abuse occurs in the area of products for
|
||||
individuals to use, which is precisely where it is most unacceptable.
|
||||
Therefore, we have designed this version of the GPL to prohibit the
|
||||
practice for those products. If such problems arise substantially in
|
||||
other domains, we stand ready to extend this provision to those
|
||||
domains in future versions of the GPL, as needed to protect the
|
||||
freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish
|
||||
to avoid the special danger that patents applied to a free program
|
||||
could make it effectively proprietary. To prevent this, the GPL
|
||||
assures that patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
### TERMS AND CONDITIONS
|
||||
|
||||
#### 0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds
|
||||
of works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of
|
||||
an exact copy. The resulting work is called a "modified version" of
|
||||
the earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user
|
||||
through a computer network, with no transfer of a copy, is not
|
||||
conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices" to
|
||||
the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
#### 1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work for
|
||||
making modifications to it. "Object code" means any non-source form of
|
||||
a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users can
|
||||
regenerate automatically from other parts of the Corresponding Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that same
|
||||
work.
|
||||
|
||||
#### 2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not convey,
|
||||
without conditions so long as your license otherwise remains in force.
|
||||
You may convey covered works to others for the sole purpose of having
|
||||
them make modifications exclusively for you, or provide you with
|
||||
facilities for running those works, provided that you comply with the
|
||||
terms of this License in conveying all material for which you do not
|
||||
control copyright. Those thus making or running the covered works for
|
||||
you must do so exclusively on your behalf, under your direction and
|
||||
control, on terms that prohibit them from making any copies of your
|
||||
copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under the
|
||||
conditions stated below. Sublicensing is not allowed; section 10 makes
|
||||
it unnecessary.
|
||||
|
||||
#### 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such
|
||||
circumvention is effected by exercising rights under this License with
|
||||
respect to the covered work, and you disclaim any intention to limit
|
||||
operation or modification of the work as a means of enforcing, against
|
||||
the work's users, your or third parties' legal rights to forbid
|
||||
circumvention of technological measures.
|
||||
|
||||
#### 4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
#### 5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these
|
||||
conditions:
|
||||
|
||||
- a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
- b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under
|
||||
section 7. This requirement modifies the requirement in section 4
|
||||
to "keep intact all notices".
|
||||
- c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
- d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
#### 6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms of
|
||||
sections 4 and 5, provided that you also convey the machine-readable
|
||||
Corresponding Source under the terms of this License, in one of these
|
||||
ways:
|
||||
|
||||
- a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
- b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the Corresponding
|
||||
Source from a network server at no charge.
|
||||
- c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
- d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
- e) Convey the object code using peer-to-peer transmission,
|
||||
provided you inform other peers where the object code and
|
||||
Corresponding Source of the work are being offered to the general
|
||||
public at no charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal,
|
||||
family, or household purposes, or (2) anything designed or sold for
|
||||
incorporation into a dwelling. In determining whether a product is a
|
||||
consumer product, doubtful cases shall be resolved in favor of
|
||||
coverage. For a particular product received by a particular user,
|
||||
"normally used" refers to a typical or common use of that class of
|
||||
product, regardless of the status of the particular user or of the way
|
||||
in which the particular user actually uses, or expects or is expected
|
||||
to use, the product. A product is a consumer product regardless of
|
||||
whether the product has substantial commercial, industrial or
|
||||
non-consumer uses, unless such uses represent the only significant
|
||||
mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to
|
||||
install and execute modified versions of a covered work in that User
|
||||
Product from a modified version of its Corresponding Source. The
|
||||
information must suffice to ensure that the continued functioning of
|
||||
the modified object code is in no case prevented or interfered with
|
||||
solely because modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or
|
||||
updates for a work that has been modified or installed by the
|
||||
recipient, or for the User Product in which it has been modified or
|
||||
installed. Access to a network may be denied when the modification
|
||||
itself materially and adversely affects the operation of the network
|
||||
or violates the rules and protocols for communication across the
|
||||
network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
#### 7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders
|
||||
of that material) supplement the terms of this License with terms:
|
||||
|
||||
- a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
- b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
- c) Prohibiting misrepresentation of the origin of that material,
|
||||
or requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
- d) Limiting the use for publicity purposes of names of licensors
|
||||
or authors of the material; or
|
||||
- e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
- f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions
|
||||
of it) with contractual assumptions of liability to the recipient,
|
||||
for any liability that these contractual assumptions directly
|
||||
impose on those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions; the
|
||||
above requirements apply either way.
|
||||
|
||||
#### 8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your license
|
||||
from a particular copyright holder is reinstated (a) provisionally,
|
||||
unless and until the copyright holder explicitly and finally
|
||||
terminates your license, and (b) permanently, if the copyright holder
|
||||
fails to notify you of the violation by some reasonable means prior to
|
||||
60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
#### 9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or run
|
||||
a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
#### 10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
#### 11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims owned
|
||||
or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within the
|
||||
scope of its coverage, prohibits the exercise of, or is conditioned on
|
||||
the non-exercise of one or more of the rights that are specifically
|
||||
granted under this License. You may not convey a covered work if you
|
||||
are a party to an arrangement with a third party that is in the
|
||||
business of distributing software, under which you make payment to the
|
||||
third party based on the extent of your activity of conveying the
|
||||
work, and under which the third party grants, to any of the parties
|
||||
who would receive the covered work from you, a discriminatory patent
|
||||
license (a) in connection with copies of the covered work conveyed by
|
||||
you (or copies made from those copies), or (b) primarily for and in
|
||||
connection with specific products or compilations that contain the
|
||||
covered work, unless you entered into that arrangement, or that patent
|
||||
license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
#### 12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under
|
||||
this License and any other pertinent obligations, then as a
|
||||
consequence you may not convey it at all. For example, if you agree to
|
||||
terms that obligate you to collect a royalty for further conveying
|
||||
from those to whom you convey the Program, the only way you could
|
||||
satisfy both those terms and this License would be to refrain entirely
|
||||
from conveying the Program.
|
||||
|
||||
#### 13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
#### 14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions
|
||||
of the GNU General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in
|
||||
detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program
|
||||
specifies that a certain numbered version of the GNU General Public
|
||||
License "or any later version" applies to it, you have the option of
|
||||
following the terms and conditions either of that numbered version or
|
||||
of any later version published by the Free Software Foundation. If the
|
||||
Program does not specify a version number of the GNU General Public
|
||||
License, you may choose any version ever published by the Free
|
||||
Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future versions
|
||||
of the GNU General Public License can be used, that proxy's public
|
||||
statement of acceptance of a version permanently authorizes you to
|
||||
choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
#### 15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT
|
||||
WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND
|
||||
PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE
|
||||
DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR
|
||||
CORRECTION.
|
||||
|
||||
#### 16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR
|
||||
CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES
|
||||
ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT
|
||||
NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR
|
||||
LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM
|
||||
TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER
|
||||
PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
#### 17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
### How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these
|
||||
terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest to
|
||||
attach them to the start of each source file to most effectively state
|
||||
the exclusion of warranty; and each file should have at least the
|
||||
"copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper
|
||||
mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands \`show w' and \`show c' should show the
|
||||
appropriate parts of the General Public License. Of course, your
|
||||
program's commands might be different; for a GUI interface, you would
|
||||
use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or
|
||||
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||
necessary. For more information on this, and how to apply and follow
|
||||
the GNU GPL, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your
|
||||
program into proprietary programs. If your program is a subroutine
|
||||
library, you may consider it more useful to permit linking proprietary
|
||||
applications with the library. If this is what you want to do, use the
|
||||
GNU Lesser General Public License instead of this License. But first,
|
||||
please read <https://www.gnu.org/licenses/why-not-lgpl.html>.
|
@ -1,36 +0,0 @@
|
||||
@ECHO OFF
|
||||
|
||||
pushd %~dp0
|
||||
|
||||
REM Command file for Sphinx documentation
|
||||
|
||||
if "%SPHINXBUILD%" == "" (
|
||||
set SPHINXBUILD=sphinx-build
|
||||
)
|
||||
set SOURCEDIR=.
|
||||
set BUILDDIR=_build
|
||||
set SPHINXPROJ=toot
|
||||
|
||||
if "%1" == "" goto help
|
||||
|
||||
%SPHINXBUILD% >NUL 2>NUL
|
||||
if errorlevel 9009 (
|
||||
echo.
|
||||
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
|
||||
echo.installed, then set the SPHINXBUILD environment variable to point
|
||||
echo.to the full path of the 'sphinx-build' executable. Alternatively you
|
||||
echo.may add the Sphinx directory to PATH.
|
||||
echo.
|
||||
echo.If you don't have Sphinx installed, grab it from
|
||||
echo.http://sphinx-doc.org/
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
|
||||
goto end
|
||||
|
||||
:help
|
||||
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
|
||||
|
||||
:end
|
||||
popd
|
43
docs/release.md
Normal file
@ -0,0 +1,43 @@
|
||||
Release procedure
|
||||
=================
|
||||
|
||||
This document is a checklist for creating a toot release.
|
||||
|
||||
Currently the process is pretty manual and would benefit from automatization.
|
||||
|
||||
Bump & tag version
|
||||
------------------
|
||||
|
||||
* Update the version number in `setup.py`
|
||||
* Update the version number in `toot/__init__.py`
|
||||
* Update `changelog.yaml` with the release notes & date
|
||||
* Run `make changelog` to generate a human readable changelog
|
||||
* Commit the changes
|
||||
* Run `./scripts/tag_version <version>` to tag a release in git
|
||||
* Run `git push --follow-tags` to upload changes and tag to GitHub
|
||||
|
||||
Publishing to PyPI
|
||||
------------------
|
||||
|
||||
* `make dist` to create source and wheel distributions
|
||||
* `make publish` to push them to PyPI
|
||||
|
||||
GitHub release
|
||||
--------------
|
||||
|
||||
* [Create a release](https://github.com/ihabunek/toot/releases/) for the newly
|
||||
pushed tag, paste changelog since last tag in the description
|
||||
* Upload the assets generated in previous two steps to the release:
|
||||
* source dist (.zip and .tar.gz)
|
||||
* wheel distribution (.whl)
|
||||
|
||||
TODO: this can be automated: https://developer.github.com/v3/repos/releases/
|
||||
|
||||
Update documentation
|
||||
--------------------
|
||||
|
||||
To regenerate HTML docs and deploy to toot.bezdomni.net:
|
||||
|
||||
```
|
||||
make docs-deploy
|
||||
```
|
@ -1,35 +0,0 @@
|
||||
=================
|
||||
Release procedure
|
||||
=================
|
||||
|
||||
This document is a checklist for creating a toot release.
|
||||
|
||||
Currently the process is pretty manual and would benefit from automatization.
|
||||
|
||||
Bump & tag version
|
||||
------------------
|
||||
|
||||
* Update the version number in ``setup.py``
|
||||
* Update the version number in ``toot/__init__.py``
|
||||
* Update ``changelog.yaml`` with the release notes & date
|
||||
* Run ``make changelog`` to generate a human readable changelog
|
||||
* Commit the changes
|
||||
* Run ``./scripts/tag_version <version>`` to tag a release in git
|
||||
* Run ``git push --follow-tags`` to upload changes and tag to GitHub
|
||||
|
||||
Publishing to PyPI
|
||||
------------------
|
||||
|
||||
* ``make dist`` to create source and wheel distributions
|
||||
* ``make publish`` to push them to PyPI
|
||||
|
||||
GitHub release
|
||||
--------------
|
||||
|
||||
* `Create a release <https://github.com/ihabunek/toot/releases/>`_ for the newly
|
||||
pushed tag, paste changelog since last tag in the description
|
||||
* Upload the assets generated in previous two steps to the release:
|
||||
* source dist (.zip and .tar.gz)
|
||||
* wheel distribution (.whl)
|
||||
|
||||
TODO: this can be automated: https://developer.github.com/v3/repos/releases/
|
126
docs/settings.md
Normal file
@ -0,0 +1,126 @@
|
||||
# Settings
|
||||
|
||||
Toot can be configured via a [TOML](https://toml.io/en/) settings file.
|
||||
|
||||
> Introduced in toot 0.37.0
|
||||
|
||||
> **Warning:** Settings are experimental and things may change without warning.
|
||||
|
||||
Toot will look for the settings file at:
|
||||
|
||||
* `~/.config/toot/settings.toml` (Linux & co.)
|
||||
* `%APPDATA%\toot\settings.toml` (Windows)
|
||||
|
||||
Toot will respect the `XDG_CONFIG_HOME` environment variable if it's set and
|
||||
look for the settings file in `$XDG_CONFIG_HOME/toot` instead of
|
||||
`~/.config/toot`.
|
||||
|
||||
## Common options
|
||||
|
||||
The `[common]` section includes common options which are applied to all commands.
|
||||
|
||||
```toml
|
||||
[common]
|
||||
# Whether to use ANSI color in output
|
||||
color = true
|
||||
|
||||
# Enable debug logging, shows HTTP requests
|
||||
debug = true
|
||||
|
||||
# Redirect debug log to the given file
|
||||
debug_file = "/tmp/toot.log"
|
||||
|
||||
# Log request and response bodies in the debug log
|
||||
verbose = false
|
||||
|
||||
# Do not write to output
|
||||
quiet = false
|
||||
```
|
||||
|
||||
## Overriding command defaults
|
||||
|
||||
Defaults for command arguments can be override by specifying a `[commands.<name>]` section.
|
||||
|
||||
For example, to override `toot post`.
|
||||
|
||||
```toml
|
||||
[commands.post]
|
||||
editor = "vim"
|
||||
sensitive = true
|
||||
visibility = "unlisted"
|
||||
scheduled_in = "30 minutes"
|
||||
```
|
||||
|
||||
## TUI view images
|
||||
|
||||
> Introduced in toot 0.39.0
|
||||
|
||||
You can view images in a toot using an external program by setting the
|
||||
`tui.media_viewer` option to your desired image viewer. When a toot is focused,
|
||||
pressing `m` will launch the specified executable giving one or more URLs as
|
||||
arguments. This works well with image viewers like `feh` which accept URLs as
|
||||
arguments.
|
||||
|
||||
```toml
|
||||
[tui]
|
||||
media_viewer = "feh"
|
||||
```
|
||||
|
||||
## TUI color palette
|
||||
|
||||
TUI uses Urwid which provides several color modes. See
|
||||
[Urwid documentation](https://urwid.org/manual/displayattributes.html)
|
||||
for more details.
|
||||
|
||||
By default, TUI operates in 16-color mode which can be changed by setting the
|
||||
`color` setting in the `[tui]` section to one of the following values:
|
||||
|
||||
* `1` (monochrome)
|
||||
* `16` (default)
|
||||
* `88`
|
||||
* `256`
|
||||
* `16777216` (24 bit)
|
||||
|
||||
TUI defines a list of colors which can be customized, currently they can be seen
|
||||
[in the source code](https://github.com/ihabunek/toot/blob/master/toot/tui/constants.py). They can be overridden in the `[tui.palette]` section.
|
||||
|
||||
Each color is defined as a list of upto 5 values:
|
||||
|
||||
* foreground color (16 color mode)
|
||||
* background color (16 color mode)
|
||||
* monochrome color (monochrome mode)
|
||||
* foreground color (high-color mode)
|
||||
* background color (high-color mode)
|
||||
|
||||
Any colors which are not used by your desired color mode can be skipped or set
|
||||
to an empty string.
|
||||
|
||||
For example, to change the button colors in 16 color mode:
|
||||
|
||||
```toml
|
||||
[tui.palette]
|
||||
button = ["dark red,bold", ""]
|
||||
button_focused = ["light gray", "green"]
|
||||
```
|
||||
|
||||
In monochrome mode:
|
||||
|
||||
```toml
|
||||
[tui]
|
||||
colors = 1
|
||||
|
||||
[tui.palette]
|
||||
button = ["", "", "bold"]
|
||||
button_focused = ["", "", "italics"]
|
||||
```
|
||||
|
||||
In 256 color mode:
|
||||
|
||||
```toml
|
||||
[tui]
|
||||
colors = 256
|
||||
|
||||
[tui.palette]
|
||||
button = ["", "", "", "#aaa", "#bbb"]
|
||||
button_focused = ["", "", "", "#aaa", "#bbb"]
|
||||
```
|
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)"
|
||||
```
|
BIN
docs/trumpet.png
Normal file
After Width: | Height: | Size: 16 KiB |
47
docs/tui.md
Normal file
@ -0,0 +1,47 @@
|
||||
TUI
|
||||
===
|
||||
|
||||
toot includes a
|
||||
[text-based user interface](https://en.wikipedia.org/wiki/Text-based_user_interface).
|
||||
Start it by running `toot tui`.
|
||||
|
||||
## Demo
|
||||
|
||||
[![asciicast](https://asciinema.org/a/563459.svg)](https://asciinema.org/a/563459)
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
Pressing `H` will bring up the help screen where all keyboard shortcuts are
|
||||
listed.
|
||||
|
||||
**Navigation**
|
||||
|
||||
* `Arrow keys` or `H/J/K/L` to move around and scroll content
|
||||
* `PageUp` and `PageDown` to scroll content
|
||||
* `Enter` or `Space` to activate buttons and menu options
|
||||
* `Esc` or `Q` to go back, close overlays and menus
|
||||
|
||||
**General**
|
||||
|
||||
* `Q` - quit toot
|
||||
* `G` - go to - switch timelines
|
||||
* `P` - save/unsave (pin) current timeline
|
||||
* `,` - refresh current timeline
|
||||
* `H` - show this help
|
||||
|
||||
**Status**
|
||||
|
||||
These commands are applied to the currently focused status.
|
||||
|
||||
* `B` - Boost/unboost status
|
||||
* `C` - Compose new status
|
||||
* `F` - Favourite/unfavourite status
|
||||
* `K` - Bookmark/unbookmark status
|
||||
* `N` - Translate status if possible (toggle)
|
||||
* `R` - Reply to current status
|
||||
* `S` - Show text marked as sensitive
|
||||
* `T` - Show status thread (replies)
|
||||
* `L` - Show the status links
|
||||
* `U` - Show the status data in JSON as received from the server
|
||||
* `V` - Open status in default browser
|
||||
* `Z` - Open status in scrollable popup window
|
176
docs/usage.md
Normal file
@ -0,0 +1,176 @@
|
||||
Usage
|
||||
=====
|
||||
|
||||
Running `toot` displays a list of available commands.
|
||||
|
||||
Running `toot <command> -h` shows the documentation for the given command.
|
||||
|
||||
Below is an overview of some common scenarios.
|
||||
|
||||
<!-- toc -->
|
||||
|
||||
Authentication
|
||||
--------------
|
||||
|
||||
Before tooting, you need to log into a Mastodon instance.
|
||||
|
||||
toot login
|
||||
|
||||
You will be redirected to your Mastodon instance to log in and authorize toot to
|
||||
access your account, and will be given an **authorization code** in return
|
||||
which you need to enter to log in.
|
||||
|
||||
The application and user access tokens will be saved in the configuration file
|
||||
located at `~/.config/toot/config.json`.
|
||||
|
||||
### Using multiple accounts
|
||||
|
||||
It's possible to be logged into multiple accounts at the same time. Just
|
||||
repeat the login process for another instance. You can see all logged in
|
||||
accounts by running `toot auth`. The currently active account will have an
|
||||
**ACTIVE** flag next to it.
|
||||
|
||||
To switch accounts, use `toot activate`. Alternatively, most commands accept a
|
||||
`--using` option which can be used to specify the account you wish to use just
|
||||
that one time.
|
||||
|
||||
Finally you can logout from an account by using `toot logout`. This will
|
||||
remove the stored access tokens for that account.
|
||||
|
||||
Post a status
|
||||
-------------
|
||||
|
||||
The simplest action is posting a status.
|
||||
|
||||
```sh
|
||||
toot post "hello there"
|
||||
```
|
||||
|
||||
You can also pipe in the status text:
|
||||
|
||||
```sh
|
||||
echo "Text to post" | toot post
|
||||
cat post.txt | toot post
|
||||
toot post < post.txt
|
||||
```
|
||||
|
||||
If no status text is given, you will be prompted to enter some:
|
||||
|
||||
```sh
|
||||
$ toot post
|
||||
Write or paste your toot. Press Ctrl-D to post it.
|
||||
```
|
||||
|
||||
Finally, you can launch your favourite editor:
|
||||
|
||||
```sh
|
||||
toot post --editor vim
|
||||
```
|
||||
|
||||
Define your editor preference in the `EDITOR` environment variable, then you
|
||||
don't need to specify it explicitly:
|
||||
|
||||
```sh
|
||||
export EDITOR=vim
|
||||
toot post --editor
|
||||
```
|
||||
|
||||
### Attachments
|
||||
|
||||
You can attach media to your status. Mastodon supports images, video and audio
|
||||
files. For details on supported formats see
|
||||
[Mastodon docs on attachments](https://docs.joinmastodon.org/user/posting/#attachments).
|
||||
|
||||
It is encouraged to add a plain-text description to the attached media for
|
||||
accessibility purposes by adding a `--description` option.
|
||||
|
||||
To attach an image:
|
||||
|
||||
```sh
|
||||
toot post "hello media" --media path/to/image.png --description "Cool image"
|
||||
```
|
||||
|
||||
You can attach upto 4 attachments by giving multiple `--media` and
|
||||
`--description` options:
|
||||
|
||||
```sh
|
||||
toot post "hello media" \
|
||||
--media path/to/image1.png --description "First image" \
|
||||
--media path/to/image2.png --description "Second image" \
|
||||
--media path/to/image3.png --description "Third image" \
|
||||
--media path/to/image4.png --description "Fourth image"
|
||||
```
|
||||
|
||||
The order of options is not relevant, except that the first given media will be
|
||||
matched to the first given description and so on.
|
||||
|
||||
If the media is sensitive, mark it as such and people will need to click to show
|
||||
it. This affects all attachments.
|
||||
|
||||
```sh
|
||||
toot post "naughty pics ahoy" --media nsfw.png --sensitive
|
||||
```
|
||||
|
||||
View timeline
|
||||
-------------
|
||||
|
||||
View what's on your home timeline:
|
||||
|
||||
```sh
|
||||
toot timeline
|
||||
```
|
||||
|
||||
Timeline takes various options:
|
||||
|
||||
```sh
|
||||
toot timeline --public # public timeline
|
||||
toot timeline --public --local # public timeline, only this instance
|
||||
toot timeline --tag photo # posts tagged with #photo
|
||||
toot timeline --count 5 # fetch 5 toots (max 20)
|
||||
toot timeline --once # don't prompt to fetch more toots
|
||||
```
|
||||
|
||||
Add `--help` to see all the options.
|
||||
|
||||
Status actions
|
||||
--------------
|
||||
|
||||
The timeline lists the status ID at the bottom of each toot. Using that status
|
||||
you can do various actions to it, e.g.:
|
||||
|
||||
```sh
|
||||
toot favourite 123456
|
||||
toot reblog 123456
|
||||
```
|
||||
|
||||
If it's your own status you can also delete pin or delete it:
|
||||
|
||||
```sh
|
||||
toot pin 123456
|
||||
toot delete 123456
|
||||
```
|
||||
|
||||
Account actions
|
||||
---------------
|
||||
|
||||
Find a user by their name or account name:
|
||||
|
||||
```sh
|
||||
toot search "name surname"
|
||||
toot search @someone
|
||||
toot search someone@someplace.social
|
||||
```
|
||||
|
||||
Once found, follow them:
|
||||
|
||||
```sh
|
||||
toot follow someone@someplace.social
|
||||
```
|
||||
|
||||
If you get bored of them:
|
||||
|
||||
```sh
|
||||
toot mute someone@someplace.social
|
||||
toot block someone@someplace.social
|
||||
toot unfollow someone@someplace.social
|
||||
```
|
248
docs/usage.rst
@ -1,248 +0,0 @@
|
||||
=====
|
||||
Usage
|
||||
=====
|
||||
|
||||
Running ``toot`` displays a list of available commands.
|
||||
|
||||
Running ``toot <command> -h`` shows the documentation for the given command.
|
||||
|
||||
.. code-block:: none
|
||||
|
||||
$ toot
|
||||
|
||||
toot - a Mastodon CLI client
|
||||
v0.27.0
|
||||
|
||||
Authentication:
|
||||
toot login Log into a mastodon instance using your browser (recommended)
|
||||
toot login_cli Log in from the console, does NOT support two factor authentication
|
||||
toot activate Switch between logged in accounts.
|
||||
toot logout Log out, delete stored access keys
|
||||
toot auth Show logged in accounts and instances
|
||||
|
||||
TUI:
|
||||
toot tui Launches the toot terminal user interface
|
||||
|
||||
Read:
|
||||
toot whoami Display logged in user details
|
||||
toot whois Display account details
|
||||
toot notifications Notifications for logged in user
|
||||
toot instance Display instance details
|
||||
toot search Search for users or hashtags
|
||||
toot thread Show toot thread items
|
||||
toot timeline Show recent items in a timeline (home by default)
|
||||
|
||||
Post:
|
||||
toot post Post a status text to your timeline
|
||||
toot upload Upload an image or video file
|
||||
|
||||
Status:
|
||||
toot delete Delete a status
|
||||
toot favourite Favourite a status
|
||||
toot unfavourite Unfavourite a status
|
||||
toot reblog Reblog a status
|
||||
toot unreblog Unreblog a status
|
||||
toot reblogged_by Show accounts that reblogged the status
|
||||
toot pin Pin a status
|
||||
toot unpin Unpin a status
|
||||
|
||||
Accounts:
|
||||
toot follow Follow an account
|
||||
toot unfollow Unfollow an account
|
||||
toot mute Mute an account
|
||||
toot unmute Unmute an account
|
||||
toot block Block an account
|
||||
toot unblock Unblock an account
|
||||
|
||||
To get help for each command run:
|
||||
toot <command> --help
|
||||
|
||||
https://github.com/ihabunek/toot
|
||||
|
||||
|
||||
Authentication
|
||||
--------------
|
||||
|
||||
Before tooting, you need to log into a Mastodon instance.
|
||||
|
||||
.. code-block:: sh
|
||||
|
||||
toot login
|
||||
|
||||
You will be redirected to your Mastodon instance to log in and authorize toot to
|
||||
access your account, and will be given an **authorization code** in return which
|
||||
you need to enter to log in.
|
||||
|
||||
The application and user access tokens will be saved in the configuration file
|
||||
located at ``~/.config/toot/config.json``.
|
||||
|
||||
Using multiple accounts
|
||||
~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
It's possible to be logged into **multiple accounts** at the same time. Just
|
||||
repeat the login process for another instance. You can see all logged in
|
||||
accounts by running ``toot auth``. The currently active account will have an
|
||||
**ACTIVE** flag next to it.
|
||||
|
||||
To switch accounts, use ``toot activate``. Alternatively, most commands accept a
|
||||
``--using`` option which can be used to specify the account you wish to use just
|
||||
that one time.
|
||||
|
||||
Finally you can logout from an account by using ``toot logout``. This will
|
||||
remove the stored access tokens for that account.
|
||||
|
||||
Post a status
|
||||
-------------
|
||||
|
||||
The simplest action is posting a status.
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
toot post "hello there"
|
||||
|
||||
You can also pipe in the status text:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
echo "Text to post" | toot post
|
||||
cat post.txt | toot post
|
||||
toot post < post.txt
|
||||
|
||||
If no status text is given, you will be prompted to enter some:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
$ toot post
|
||||
Write or paste your toot. Press Ctrl-D to post it.
|
||||
|
||||
Finally, you can launch your favourite editor:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
toot post --editor vim
|
||||
|
||||
Define your editor preference in the ``EDITOR`` environment variable, then you
|
||||
don't need to specify it explicitly:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
export EDITOR=vim
|
||||
toot post --editor
|
||||
|
||||
Attachments
|
||||
~~~~~~~~~~~
|
||||
|
||||
You can attach media to your status. Mastodon supports images, video and audio
|
||||
files. For details on supported formats see `Mastodon docs on attachments
|
||||
<https://docs.joinmastodon.org/user/posting/#attachments>`_.
|
||||
|
||||
It is encouraged to add a plain-text description to the attached media for
|
||||
accessibility purposes by adding a ``--description`` option.
|
||||
|
||||
To attach an image:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
toot post "hello media" --media path/to/image.png --description "Cool image"
|
||||
|
||||
You can attach upto 4 attachments by giving multiple ``--media`` and
|
||||
``--description`` options:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
toot post "hello media" \
|
||||
--media path/to/image1.png --description "First image" \
|
||||
--media path/to/image2.png --description "Second image" \
|
||||
--media path/to/image3.png --description "Third image" \
|
||||
--media path/to/image4.png --description "Fourth image"
|
||||
|
||||
The order of options is not relevant, except that the first given media will be
|
||||
matched to the first given description and so on.
|
||||
|
||||
If the media is sensitive, mark it as such and people will need to click to show
|
||||
it. This affects all attachments.
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
toot post "naughty pics ahoy" --media nsfw.png --sensitive
|
||||
|
||||
View timeline
|
||||
-------------
|
||||
|
||||
View what's on your home timeline:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
toot timeline
|
||||
|
||||
Timeline takes various options:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
toot timeline --public # public timeline
|
||||
toot timeline --public --local # public timeline, only this instance
|
||||
toot timeline --tag photo # posts tagged with #photo
|
||||
toot timeline --count 5 # fetch 5 toots (max 20)
|
||||
toot timeline --once # don't prompt to fetch more toots
|
||||
|
||||
Status actions
|
||||
--------------
|
||||
|
||||
The timeline lists the status ID at the bottom of each toot. Using that status
|
||||
you can do various actions to it, e.g.:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
toot favourite 123456
|
||||
toot reblog 123456
|
||||
|
||||
If it's your own status you can also delete pin or delete it:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
toot pin 123456
|
||||
toot delete 123456
|
||||
|
||||
Account actions
|
||||
---------------
|
||||
|
||||
Find a user by their name or account name:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
toot search "name surname"
|
||||
toot search @someone
|
||||
toot search someone@someplace.social
|
||||
|
||||
Once found, follow them:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
toot follow someone@someplace.social
|
||||
|
||||
If you get bored of them:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
toot mute someone@someplace.social
|
||||
toot block someone@someplace.social
|
||||
toot unfollow someone@someplace.social
|
||||
|
||||
Using the Curses UI
|
||||
-------------------
|
||||
|
||||
toot has a curses-based terminal user interface. The command to start it is ``toot tui``.
|
||||
|
||||
To navigate the UI use these commands:
|
||||
|
||||
* ``k`` or ``up arrow`` to move up the list of tweets
|
||||
* ``j`` or ``down arrow`` to move down the list of tweets
|
||||
* ``h`` to show a help screen
|
||||
* ``t`` to view status thread
|
||||
* ``v`` to view the current toot in a browser
|
||||
* ``b`` to boost or unboost a status
|
||||
* ``f`` to favourite or unfavourite a status
|
||||
* ``q`` to quit the curses interface and return to the command line
|
||||
* ``s`` to show sensitive content. (This is per-toot, and there will be a read bar in the toot to indicate that it is there.)
|
||||
|
||||
*Note that the curses UI is not available on Windows.*
|
2
pytest.ini
Normal file
@ -0,0 +1,2 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
@ -1,8 +0,0 @@
|
||||
coverage
|
||||
keyring
|
||||
pyxdg
|
||||
pyyaml
|
||||
sphinx
|
||||
sphinx-autobuild
|
||||
twine
|
||||
wheel
|
@ -1,5 +0,0 @@
|
||||
flake8
|
||||
psycopg2-binary
|
||||
pytest
|
||||
pytest-xdist[psutil]
|
||||
vermin
|
@ -1,4 +0,0 @@
|
||||
requests>=2.13,<3.0
|
||||
beautifulsoup4>=4.5.0,<5.0
|
||||
wcwidth>=0.1.7
|
||||
urwid>=2.0.0,<3.0
|
@ -21,6 +21,13 @@ for version in data.keys():
|
||||
changes = data[version]["changes"]
|
||||
print(f"**{version} ({date})**")
|
||||
print()
|
||||
|
||||
if "description" in data[version]:
|
||||
description = data[version]["description"].strip()
|
||||
for line in textwrap.wrap(description, 80):
|
||||
print(line)
|
||||
print()
|
||||
|
||||
for c in changes:
|
||||
lines = textwrap.wrap(c, 78)
|
||||
initial = True
|
||||
|
@ -43,6 +43,7 @@ if dist_version != version:
|
||||
sys.exit(1)
|
||||
|
||||
release_date = changelog_item["date"]
|
||||
description = changelog_item.get("description")
|
||||
changes = changelog_item["changes"]
|
||||
|
||||
if not isinstance(release_date, date):
|
||||
@ -50,6 +51,11 @@ if not isinstance(release_date, date):
|
||||
sys.exit(1)
|
||||
|
||||
commit_message = f"toot {version}\n\n"
|
||||
|
||||
if description:
|
||||
lines = textwrap.wrap(description.strip(), 72)
|
||||
commit_message += "\n".join(lines) + "\n\n"
|
||||
|
||||
for c in changes:
|
||||
lines = textwrap.wrap(c, 70)
|
||||
initial = True
|
||||
|
33
setup.py
@ -12,14 +12,14 @@ and blocking accounts and other actions.
|
||||
|
||||
setup(
|
||||
name='toot',
|
||||
version='0.33.1',
|
||||
version='0.41.1',
|
||||
description='Mastodon CLI client',
|
||||
long_description=long_description.strip(),
|
||||
author='Ivan Habunek',
|
||||
author_email='ivan@habunek.com',
|
||||
url='https://github.com/ihabunek/toot/',
|
||||
project_urls={
|
||||
'Documentation': 'https://toot.readthedocs.io/en/latest/',
|
||||
'Documentation': 'https://toot.bezdomni.net/',
|
||||
'Issue tracker': 'https://github.com/ihabunek/toot/issues/',
|
||||
},
|
||||
keywords='mastodon toot',
|
||||
@ -31,17 +31,40 @@ setup(
|
||||
'License :: OSI Approved :: GNU General Public License v3 (GPLv3)',
|
||||
'Programming Language :: Python :: 3',
|
||||
],
|
||||
packages=['toot', 'toot.tui', 'toot.utils'],
|
||||
python_requires=">=3.6",
|
||||
packages=['toot', 'toot.cli', 'toot.tui', 'toot.tui.richtext', 'toot.utils'],
|
||||
python_requires=">=3.7",
|
||||
install_requires=[
|
||||
"click~=8.1",
|
||||
"requests>=2.13,<3.0",
|
||||
"beautifulsoup4>=4.5.0,<5.0",
|
||||
"wcwidth>=0.1.7",
|
||||
"urwid>=2.0.0,<3.0",
|
||||
"tomlkit>=0.10.0,<1.0"
|
||||
],
|
||||
extras_require={
|
||||
# Required to display rich text in the TUI
|
||||
"richtext": [
|
||||
"urwidgets>=0.1,<0.2"
|
||||
],
|
||||
"dev": [
|
||||
"coverage",
|
||||
"pyyaml",
|
||||
"twine",
|
||||
"wheel",
|
||||
],
|
||||
"test": [
|
||||
"flake8",
|
||||
"psycopg2-binary",
|
||||
"pytest",
|
||||
"pytest-xdist[psutil]",
|
||||
"setuptools",
|
||||
"vermin",
|
||||
"typing-extensions",
|
||||
],
|
||||
},
|
||||
entry_points={
|
||||
'console_scripts': [
|
||||
'toot=toot.console:main',
|
||||
'toot=toot.cli:cli',
|
||||
],
|
||||
}
|
||||
)
|
||||
|
BIN
tests/assets/small.webm
Normal file
0
tests/integration/__init__.py
Normal file
155
tests/integration/conftest.py
Normal file
@ -0,0 +1,155 @@
|
||||
"""
|
||||
This module contains integration tests meant to run against a test Mastodon instance.
|
||||
|
||||
You can set up a test instance locally by following this guide:
|
||||
https://docs.joinmastodon.org/dev/setup/
|
||||
|
||||
To enable integration tests, export the following environment variables to match
|
||||
your test server and database:
|
||||
|
||||
```
|
||||
export TOOT_TEST_BASE_URL="localhost:3000"
|
||||
```
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import pytest
|
||||
import re
|
||||
import typing as t
|
||||
import uuid
|
||||
|
||||
from click.testing import CliRunner, Result
|
||||
from pathlib import Path
|
||||
from toot import api, App, User
|
||||
from toot.cli import Context, TootObj
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
import toot.settings
|
||||
toot.settings.DISABLE_SETTINGS = True
|
||||
|
||||
|
||||
# Type alias for run commands
|
||||
Run = t.Callable[..., Result]
|
||||
|
||||
# Mastodon database name, used to confirm user registration without having to click the link
|
||||
TOOT_TEST_BASE_URL = os.getenv("TOOT_TEST_BASE_URL")
|
||||
|
||||
# Toot logo used for testing image upload
|
||||
TRUMPET = str(Path(__file__).parent.parent.parent / "trumpet.png")
|
||||
|
||||
ASSETS_DIR = str(Path(__file__).parent.parent / "assets")
|
||||
|
||||
|
||||
def create_app(base_url):
|
||||
instance = api.get_instance(base_url).json()
|
||||
response = api.create_app(base_url)
|
||||
return App(instance["uri"], base_url, response["client_id"], response["client_secret"])
|
||||
|
||||
|
||||
def register_account(app: App):
|
||||
username = str(uuid.uuid4())[-10:]
|
||||
email = f"{username}@example.com"
|
||||
|
||||
response = api.register_account(app, username, email, "password", "en")
|
||||
return User(app.instance, username, response["access_token"])
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Host name of a test instance to run integration tests against
|
||||
# DO NOT USE PUBLIC INSTANCES!!!
|
||||
@pytest.fixture(scope="session")
|
||||
def base_url():
|
||||
if not TOOT_TEST_BASE_URL:
|
||||
pytest.skip("Skipping integration tests, TOOT_TEST_BASE_URL not set")
|
||||
|
||||
return TOOT_TEST_BASE_URL
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def app(base_url):
|
||||
return create_app(base_url)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def user(app):
|
||||
return register_account(app)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def friend(app):
|
||||
return register_account(app)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def user_id(app, user):
|
||||
return api.find_account(app, user, user.username)["id"]
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def friend_id(app, user, friend):
|
||||
return api.find_account(app, user, friend.username)["id"]
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def testing_env():
|
||||
os.environ["TOOT_TESTING"] = "true"
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def runner():
|
||||
return CliRunner(mix_stderr=False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def run(app, user, runner):
|
||||
def _run(command, *params, input=None) -> Result:
|
||||
obj = TootObj(test_ctx=Context(app, user))
|
||||
return runner.invoke(command, params, obj=obj, input=input)
|
||||
return _run
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def run_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):
|
||||
obj = TootObj(test_ctx=Context(app, user))
|
||||
result = runner.invoke(command, params, obj=obj)
|
||||
assert result.exit_code == 0
|
||||
return json.loads(result.stdout)
|
||||
return _run_json
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def run_anon(runner):
|
||||
def _run(command, *params) -> Result:
|
||||
obj = TootObj(test_ctx=Context(None, None))
|
||||
return runner.invoke(command, params, obj=obj)
|
||||
return _run
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Utils
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def posted_status_id(out):
|
||||
pattern = re.compile(r"Toot posted: http://([^/]+)/([^/]+)/(.+)")
|
||||
match = re.search(pattern, out)
|
||||
assert match
|
||||
|
||||
_, _, status_id = match.groups()
|
||||
|
||||
return status_id
|
274
tests/integration/test_accounts.py
Normal file
@ -0,0 +1,274 @@
|
||||
import json
|
||||
|
||||
from toot import App, User, api, cli
|
||||
from toot.entities import Account, Relationship, from_dict
|
||||
|
||||
|
||||
def test_whoami(user: User, run):
|
||||
result = run(cli.read.whoami)
|
||||
assert result.exit_code == 0
|
||||
|
||||
# TODO: test other fields once updating account is supported
|
||||
out = result.stdout.strip()
|
||||
assert f"@{user.username}" in out
|
||||
|
||||
|
||||
def test_whoami_json(user: User, run):
|
||||
result = run(cli.read.whoami, "--json")
|
||||
assert result.exit_code == 0
|
||||
|
||||
account = from_dict(Account, json.loads(result.stdout))
|
||||
assert account.username == user.username
|
||||
|
||||
|
||||
def test_whois(app: App, friend: User, run):
|
||||
variants = [
|
||||
friend.username,
|
||||
f"@{friend.username}",
|
||||
f"{friend.username}@{app.instance}",
|
||||
f"@{friend.username}@{app.instance}",
|
||||
]
|
||||
|
||||
for username in variants:
|
||||
result = run(cli.read.whois, username)
|
||||
assert result.exit_code == 0
|
||||
assert f"@{friend.username}" in result.stdout
|
||||
|
||||
|
||||
def test_following(app: App, user: User, friend: User, friend_id, run):
|
||||
# Make sure we're not initially following friend
|
||||
api.unfollow(app, user, friend_id)
|
||||
|
||||
result = run(cli.accounts.following, user.username)
|
||||
assert result.exit_code == 0
|
||||
assert result.stdout.strip() == ""
|
||||
|
||||
result = run(cli.accounts.follow, friend.username)
|
||||
assert result.exit_code == 0
|
||||
assert result.stdout.strip() == f"✓ You are now following {friend.username}"
|
||||
|
||||
result = run(cli.accounts.following, user.username)
|
||||
assert result.exit_code == 0
|
||||
assert friend.username in result.stdout.strip()
|
||||
|
||||
# If no account is given defaults to logged in user
|
||||
result = run(cli.accounts.following)
|
||||
assert result.exit_code == 0
|
||||
assert friend.username in result.stdout.strip()
|
||||
|
||||
result = run(cli.accounts.unfollow, friend.username)
|
||||
assert result.exit_code == 0
|
||||
assert result.stdout.strip() == f"✓ You are no longer following {friend.username}"
|
||||
|
||||
result = run(cli.accounts.following, user.username)
|
||||
assert result.exit_code == 0
|
||||
assert result.stdout.strip() == ""
|
||||
|
||||
|
||||
def test_following_case_insensitive(user: User, friend: User, run):
|
||||
assert friend.username != friend.username.upper()
|
||||
result = run(cli.accounts.follow, friend.username.upper())
|
||||
assert result.exit_code == 0
|
||||
|
||||
out = result.stdout.strip()
|
||||
assert out == f"✓ You are now following {friend.username.upper()}"
|
||||
|
||||
|
||||
def test_following_not_found(run):
|
||||
result = run(cli.accounts.follow, "bananaman")
|
||||
assert result.exit_code == 1
|
||||
assert result.stderr.strip() == "Error: Account not found"
|
||||
|
||||
result = run(cli.accounts.unfollow, "bananaman")
|
||||
assert result.exit_code == 1
|
||||
assert result.stderr.strip() == "Error: Account not found"
|
||||
|
||||
|
||||
def test_following_json(app: App, user: User, friend: User, user_id, friend_id, run_json):
|
||||
# Make sure we're not initially following friend
|
||||
api.unfollow(app, user, friend_id)
|
||||
|
||||
result = run_json(cli.accounts.following, user.username, "--json")
|
||||
assert result == []
|
||||
|
||||
result = run_json(cli.accounts.followers, friend.username, "--json")
|
||||
assert result == []
|
||||
|
||||
result = run_json(cli.accounts.follow, friend.username, "--json")
|
||||
relationship = from_dict(Relationship, result)
|
||||
assert relationship.id == friend_id
|
||||
assert relationship.following is True
|
||||
|
||||
[result] = run_json(cli.accounts.following, user.username, "--json")
|
||||
relationship = from_dict(Relationship, result)
|
||||
assert relationship.id == friend_id
|
||||
|
||||
# If no account is given defaults to logged in user
|
||||
[result] = run_json(cli.accounts.following, user.username, "--json")
|
||||
relationship = from_dict(Relationship, result)
|
||||
assert relationship.id == friend_id
|
||||
|
||||
[result] = run_json(cli.accounts.followers, friend.username, "--json")
|
||||
assert result["id"] == user_id
|
||||
|
||||
result = run_json(cli.accounts.unfollow, friend.username, "--json")
|
||||
assert result["id"] == friend_id
|
||||
assert result["following"] is False
|
||||
|
||||
result = run_json(cli.accounts.following, user.username, "--json")
|
||||
assert result == []
|
||||
|
||||
result = run_json(cli.accounts.followers, friend.username, "--json")
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_mute(app, user, friend, friend_id, run):
|
||||
# Make sure we're not initially muting friend
|
||||
api.unmute(app, user, friend_id)
|
||||
|
||||
result = run(cli.accounts.muted)
|
||||
assert result.exit_code == 0
|
||||
|
||||
out = result.stdout.strip()
|
||||
assert out == "No accounts muted"
|
||||
|
||||
result = run(cli.accounts.mute, friend.username)
|
||||
assert result.exit_code == 0
|
||||
|
||||
out = result.stdout.strip()
|
||||
assert out == f"✓ You have muted {friend.username}"
|
||||
|
||||
result = run(cli.accounts.muted)
|
||||
assert result.exit_code == 0
|
||||
|
||||
out = result.stdout.strip()
|
||||
assert friend.username in out
|
||||
|
||||
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"
|
||||
|
||||
result = run(cli.accounts.muted)
|
||||
assert result.exit_code == 0
|
||||
|
||||
out = result.stdout.strip()
|
||||
assert out == "No accounts muted"
|
||||
|
||||
|
||||
def test_mute_case_insensitive(friend: User, run):
|
||||
result = run(cli.accounts.mute, friend.username.upper())
|
||||
assert result.exit_code == 0
|
||||
|
||||
out = result.stdout.strip()
|
||||
assert out == f"✓ You have muted {friend.username.upper()}"
|
||||
|
||||
|
||||
def test_mute_not_found(run):
|
||||
result = run(cli.accounts.mute, "doesnotexistperson")
|
||||
assert result.exit_code == 1
|
||||
assert result.stderr.strip() == "Error: Account not found"
|
||||
|
||||
result = run(cli.accounts.unmute, "doesnotexistperson")
|
||||
assert result.exit_code == 1
|
||||
assert result.stderr.strip() == "Error: Account not found"
|
||||
|
||||
|
||||
def test_mute_json(app: App, user: User, friend: User, run_json, friend_id):
|
||||
# Make sure we're not initially muting friend
|
||||
api.unmute(app, user, friend_id)
|
||||
|
||||
result = run_json(cli.accounts.muted, "--json")
|
||||
assert result == []
|
||||
|
||||
result = run_json(cli.accounts.mute, friend.username, "--json")
|
||||
relationship = from_dict(Relationship, result)
|
||||
assert relationship.id == friend_id
|
||||
assert relationship.muting is True
|
||||
|
||||
[result] = run_json(cli.accounts.muted, "--json")
|
||||
account = from_dict(Account, result)
|
||||
assert account.id == friend_id
|
||||
|
||||
result = run_json(cli.accounts.unmute, friend.username, "--json")
|
||||
relationship = from_dict(Relationship, result)
|
||||
assert relationship.id == friend_id
|
||||
assert relationship.muting is False
|
||||
|
||||
result = run_json(cli.accounts.muted, "--json")
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_block(app, user, friend, friend_id, run):
|
||||
# Make sure we're not initially blocking friend
|
||||
api.unblock(app, user, friend_id)
|
||||
|
||||
result = run(cli.accounts.blocked)
|
||||
assert result.exit_code == 0
|
||||
|
||||
out = result.stdout.strip()
|
||||
assert out == "No accounts blocked"
|
||||
|
||||
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}"
|
||||
|
||||
result = run(cli.accounts.blocked)
|
||||
assert result.exit_code == 0
|
||||
|
||||
out = result.stdout.strip()
|
||||
assert friend.username in out
|
||||
|
||||
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"
|
||||
|
||||
result = run(cli.accounts.blocked)
|
||||
assert result.exit_code == 0
|
||||
|
||||
out = result.stdout.strip()
|
||||
assert out == "No accounts blocked"
|
||||
|
||||
|
||||
def test_block_case_insensitive(friend: User, run):
|
||||
result = run(cli.accounts.block, friend.username.upper())
|
||||
assert result.exit_code == 0
|
||||
|
||||
out = result.stdout.strip()
|
||||
assert out == f"✓ You are now blocking {friend.username.upper()}"
|
||||
|
||||
|
||||
def test_block_not_found(run):
|
||||
result = run(cli.accounts.block, "doesnotexistperson")
|
||||
assert result.exit_code == 1
|
||||
assert result.stderr.strip() == "Error: Account not found"
|
||||
|
||||
|
||||
def test_block_json(app: App, user: User, friend: User, run_json, friend_id):
|
||||
# Make sure we're not initially blocking friend
|
||||
api.unblock(app, user, friend_id)
|
||||
|
||||
result = run_json(cli.accounts.blocked, "--json")
|
||||
assert result == []
|
||||
|
||||
result = run_json(cli.accounts.block, friend.username, "--json")
|
||||
relationship = from_dict(Relationship, result)
|
||||
assert relationship.id == friend_id
|
||||
assert relationship.blocking is True
|
||||
|
||||
[result] = run_json(cli.accounts.blocked, "--json")
|
||||
account = from_dict(Account, result)
|
||||
assert account.id == friend_id
|
||||
|
||||
result = run_json(cli.accounts.unblock, friend.username, "--json")
|
||||
relationship = from_dict(Relationship, result)
|
||||
assert relationship.id == friend_id
|
||||
assert relationship.blocking is False
|
||||
|
||||
result = run_json(cli.accounts.blocked, "--json")
|
||||
assert result == []
|
217
tests/integration/test_auth.py
Normal file
@ -0,0 +1,217 @@
|
||||
from typing import Any, Dict
|
||||
from unittest import mock
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from toot import User, cli
|
||||
from tests.integration.conftest import Run
|
||||
|
||||
# TODO: figure out how to test login
|
||||
|
||||
|
||||
EMPTY_CONFIG: Dict[Any, Any] = {
|
||||
"apps": {},
|
||||
"users": {},
|
||||
"active_user": None
|
||||
}
|
||||
|
||||
SAMPLE_CONFIG = {
|
||||
"active_user": "frank@foo.social",
|
||||
"apps": {
|
||||
"foo.social": {
|
||||
"base_url": "http://foo.social",
|
||||
"client_id": "123",
|
||||
"client_secret": "123",
|
||||
"instance": "foo.social"
|
||||
},
|
||||
"bar.social": {
|
||||
"base_url": "http://bar.social",
|
||||
"client_id": "123",
|
||||
"client_secret": "123",
|
||||
"instance": "bar.social"
|
||||
},
|
||||
},
|
||||
"users": {
|
||||
"frank@foo.social": {
|
||||
"access_token": "123",
|
||||
"instance": "foo.social",
|
||||
"username": "frank"
|
||||
},
|
||||
"frank@bar.social": {
|
||||
"access_token": "123",
|
||||
"instance": "bar.social",
|
||||
"username": "frank"
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_env(run: Run):
|
||||
result = run(cli.auth.env)
|
||||
assert result.exit_code == 0
|
||||
assert "toot" in result.stdout
|
||||
assert "Python" in result.stdout
|
||||
|
||||
|
||||
@mock.patch("toot.config.load_config")
|
||||
def test_auth_empty(load_config: MagicMock, run: Run):
|
||||
load_config.return_value = EMPTY_CONFIG
|
||||
result = run(cli.auth.auth)
|
||||
assert result.exit_code == 0
|
||||
assert result.stdout.strip() == "You are not logged in to any accounts"
|
||||
|
||||
|
||||
@mock.patch("toot.config.load_config")
|
||||
def test_auth_full(load_config: MagicMock, run: Run):
|
||||
load_config.return_value = SAMPLE_CONFIG
|
||||
result = run(cli.auth.auth)
|
||||
assert result.exit_code == 0
|
||||
assert result.stdout.strip().startswith("Authenticated accounts:")
|
||||
assert "frank@foo.social" in result.stdout
|
||||
assert "frank@bar.social" in result.stdout
|
||||
|
||||
|
||||
# Saving config is mocked so we don't mess up our local config
|
||||
# TODO: could this be implemented using an auto-use fixture so we have it always
|
||||
# mocked?
|
||||
@mock.patch("toot.config.load_app")
|
||||
@mock.patch("toot.config.save_app")
|
||||
@mock.patch("toot.config.save_user")
|
||||
def test_login_cli(
|
||||
save_user: MagicMock,
|
||||
save_app: MagicMock,
|
||||
load_app: MagicMock,
|
||||
user: User,
|
||||
run: Run,
|
||||
):
|
||||
load_app.return_value = None
|
||||
|
||||
result = run(
|
||||
cli.auth.login_cli,
|
||||
"--instance", "http://localhost:3000",
|
||||
"--email", f"{user.username}@example.com",
|
||||
"--password", "password",
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "✓ Successfully logged in." in result.stdout
|
||||
|
||||
save_app.assert_called_once()
|
||||
(app,) = save_app.call_args.args
|
||||
assert app.instance == "localhost:3000"
|
||||
assert app.base_url == "http://localhost:3000"
|
||||
assert app.client_id
|
||||
assert app.client_secret
|
||||
|
||||
save_user.assert_called_once()
|
||||
(new_user,) = save_user.call_args.args
|
||||
assert new_user.instance == "localhost:3000"
|
||||
assert new_user.username == user.username
|
||||
# access token will be different since this is a new login
|
||||
assert new_user.access_token and new_user.access_token != user.access_token
|
||||
assert save_user.call_args.kwargs == {"activate": True}
|
||||
|
||||
|
||||
@mock.patch("toot.config.load_app")
|
||||
@mock.patch("toot.config.save_app")
|
||||
@mock.patch("toot.config.save_user")
|
||||
def test_login_cli_wrong_password(
|
||||
save_user: MagicMock,
|
||||
save_app: MagicMock,
|
||||
load_app: MagicMock,
|
||||
user: User,
|
||||
run: Run,
|
||||
):
|
||||
load_app.return_value = None
|
||||
|
||||
result = run(
|
||||
cli.auth.login_cli,
|
||||
"--instance", "http://localhost:3000",
|
||||
"--email", f"{user.username}@example.com",
|
||||
"--password", "wrong password",
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert result.stderr.strip() == "Error: Login failed"
|
||||
|
||||
save_app.assert_called_once()
|
||||
(app,) = save_app.call_args.args
|
||||
assert app.instance == "localhost:3000"
|
||||
assert app.base_url == "http://localhost:3000"
|
||||
assert app.client_id
|
||||
assert app.client_secret
|
||||
|
||||
save_user.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("toot.config.load_config")
|
||||
@mock.patch("toot.config.delete_user")
|
||||
def test_logout(delete_user: MagicMock, load_config: MagicMock, run: Run):
|
||||
load_config.return_value = SAMPLE_CONFIG
|
||||
|
||||
result = run(cli.auth.logout, "frank@foo.social")
|
||||
assert result.exit_code == 0
|
||||
assert result.stdout.strip() == "✓ Account frank@foo.social logged out"
|
||||
delete_user.assert_called_once_with(User("foo.social", "frank", "123"))
|
||||
|
||||
|
||||
@mock.patch("toot.config.load_config")
|
||||
def test_logout_not_logged_in(load_config: MagicMock, run: Run):
|
||||
load_config.return_value = EMPTY_CONFIG
|
||||
|
||||
result = run(cli.auth.logout)
|
||||
assert result.exit_code == 1
|
||||
assert result.stderr.strip() == "Error: You're not logged into any accounts"
|
||||
|
||||
|
||||
@mock.patch("toot.config.load_config")
|
||||
def test_logout_account_not_specified(load_config: MagicMock, run: Run):
|
||||
load_config.return_value = SAMPLE_CONFIG
|
||||
|
||||
result = run(cli.auth.logout)
|
||||
assert result.exit_code == 1
|
||||
assert result.stderr.startswith("Error: Specify account to log out")
|
||||
|
||||
|
||||
@mock.patch("toot.config.load_config")
|
||||
def test_logout_account_does_not_exist(load_config: MagicMock, run: Run):
|
||||
load_config.return_value = SAMPLE_CONFIG
|
||||
|
||||
result = run(cli.auth.logout, "banana")
|
||||
assert result.exit_code == 1
|
||||
assert result.stderr.startswith("Error: Account not found")
|
||||
|
||||
|
||||
@mock.patch("toot.config.load_config")
|
||||
@mock.patch("toot.config.activate_user")
|
||||
def test_activate(activate_user: MagicMock, load_config: MagicMock, run: Run):
|
||||
load_config.return_value = SAMPLE_CONFIG
|
||||
|
||||
result = run(cli.auth.activate, "frank@foo.social")
|
||||
assert result.exit_code == 0
|
||||
assert result.stdout.strip() == "✓ Account frank@foo.social activated"
|
||||
activate_user.assert_called_once_with(User("foo.social", "frank", "123"))
|
||||
|
||||
|
||||
@mock.patch("toot.config.load_config")
|
||||
def test_activate_not_logged_in(load_config: MagicMock, run: Run):
|
||||
load_config.return_value = EMPTY_CONFIG
|
||||
|
||||
result = run(cli.auth.activate)
|
||||
assert result.exit_code == 1
|
||||
assert result.stderr.strip() == "Error: You're not logged into any accounts"
|
||||
|
||||
|
||||
@mock.patch("toot.config.load_config")
|
||||
def test_activate_account_not_given(load_config: MagicMock, run: Run):
|
||||
load_config.return_value = SAMPLE_CONFIG
|
||||
|
||||
result = run(cli.auth.activate)
|
||||
assert result.exit_code == 1
|
||||
assert result.stderr.startswith("Error: Specify account to activate")
|
||||
|
||||
|
||||
@mock.patch("toot.config.load_config")
|
||||
def test_activate_invalid_Account(load_config: MagicMock, run: Run):
|
||||
load_config.return_value = SAMPLE_CONFIG
|
||||
|
||||
result = run(cli.auth.activate, "banana")
|
||||
assert result.exit_code == 1
|
||||
assert result.stderr.startswith("Error: Account not found")
|
162
tests/integration/test_lists.py
Normal file
@ -0,0 +1,162 @@
|
||||
from uuid import uuid4
|
||||
from toot import cli
|
||||
|
||||
from tests.integration.conftest import register_account
|
||||
|
||||
|
||||
def test_lists_empty(run):
|
||||
result = run(cli.lists.list)
|
||||
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):
|
||||
result = run(cli.lists.create, "banana")
|
||||
assert result.exit_code == 0
|
||||
assert result.stdout.strip() == '✓ List "banana" created.'
|
||||
|
||||
result = run(cli.lists.list)
|
||||
assert result.exit_code == 0
|
||||
assert "banana" in result.stdout
|
||||
|
||||
result = run(cli.lists.create, "mango")
|
||||
assert result.exit_code == 0
|
||||
assert result.stdout.strip() == '✓ List "mango" created.'
|
||||
|
||||
result = run(cli.lists.list)
|
||||
assert result.exit_code == 0
|
||||
assert "banana" in result.stdout
|
||||
assert "mango" in result.stdout
|
||||
|
||||
result = run(cli.lists.delete, "banana")
|
||||
assert result.exit_code == 0
|
||||
assert result.stdout.strip() == '✓ List "banana" deleted.'
|
||||
|
||||
result = run(cli.lists.list)
|
||||
assert result.exit_code == 0
|
||||
assert "banana" not in result.stdout
|
||||
assert "mango" in result.stdout
|
||||
|
||||
result = run(cli.lists.delete, "mango")
|
||||
assert result.exit_code == 0
|
||||
assert result.stdout.strip() == '✓ List "mango" deleted.'
|
||||
|
||||
result = run(cli.lists.list)
|
||||
assert result.exit_code == 0
|
||||
assert result.stdout.strip() == "You have no lists defined."
|
||||
|
||||
result = run(cli.lists.delete, "mango")
|
||||
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):
|
||||
list_name = str(uuid4())
|
||||
acc = register_account(app)
|
||||
run(cli.lists.create, list_name)
|
||||
|
||||
result = run(cli.lists.add, list_name, acc.username)
|
||||
assert result.exit_code == 1
|
||||
assert result.stderr.strip() == f"Error: You must follow @{acc.username} before adding this account to a list."
|
||||
|
||||
run(cli.accounts.follow, acc.username)
|
||||
|
||||
result = run(cli.lists.add, list_name, acc.username)
|
||||
assert result.exit_code == 0
|
||||
assert result.stdout.strip() == f'✓ Added account "{acc.username}"'
|
||||
|
||||
result = run(cli.lists.accounts, list_name)
|
||||
assert result.exit_code == 0
|
||||
assert acc.username in result.stdout
|
||||
|
||||
# Account doesn't exist
|
||||
result = run(cli.lists.add, list_name, "does_not_exist")
|
||||
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)
|
||||
assert result.exit_code == 1
|
||||
assert result.stderr.strip() == "Error: List not found"
|
||||
|
||||
result = run(cli.lists.remove, list_name, acc.username)
|
||||
assert result.exit_code == 0
|
||||
assert result.stdout.strip() == f'✓ Removed account "{acc.username}"'
|
||||
|
||||
result = run(cli.lists.accounts, list_name)
|
||||
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 == []
|
363
tests/integration/test_post.py
Normal file
@ -0,0 +1,363 @@
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from os import path
|
||||
from tests.integration.conftest import ASSETS_DIR, posted_status_id
|
||||
from toot import CLIENT_NAME, CLIENT_WEBSITE, api, cli
|
||||
from toot.utils import get_text
|
||||
from unittest import mock
|
||||
|
||||
|
||||
def test_post(app, user, run):
|
||||
text = "i wish i was a #lumberjack"
|
||||
result = run(cli.post.post, text)
|
||||
assert result.exit_code == 0
|
||||
|
||||
status_id = posted_status_id(result.stdout)
|
||||
|
||||
status = api.fetch_status(app, user, status_id).json()
|
||||
assert text == get_text(status["content"])
|
||||
assert status["visibility"] == "public"
|
||||
assert status["sensitive"] is False
|
||||
assert status["spoiler_text"] == ""
|
||||
assert status["poll"] is None
|
||||
|
||||
# Pleroma doesn't return the application
|
||||
if status["application"]:
|
||||
assert status["application"]["name"] == CLIENT_NAME
|
||||
assert status["application"]["website"] == CLIENT_WEBSITE
|
||||
|
||||
|
||||
def test_post_no_text(run):
|
||||
result = run(cli.post.post)
|
||||
assert result.exit_code == 1
|
||||
assert result.stderr.strip() == "Error: You must specify either text or media to post."
|
||||
|
||||
|
||||
def test_post_json(run):
|
||||
content = "i wish i was a #lumberjack"
|
||||
result = run(cli.post.post, content, "--json")
|
||||
assert result.exit_code == 0
|
||||
|
||||
status = json.loads(result.stdout)
|
||||
assert get_text(status["content"]) == content
|
||||
assert status["visibility"] == "public"
|
||||
assert status["sensitive"] is False
|
||||
assert status["spoiler_text"] == ""
|
||||
assert status["poll"] is None
|
||||
|
||||
|
||||
def test_post_visibility(app, user, run):
|
||||
for visibility in ["public", "unlisted", "private", "direct"]:
|
||||
result = run(cli.post.post, "foo", "--visibility", visibility)
|
||||
assert result.exit_code == 0
|
||||
|
||||
status_id = posted_status_id(result.stdout)
|
||||
status = api.fetch_status(app, user, status_id).json()
|
||||
assert status["visibility"] == visibility
|
||||
|
||||
|
||||
def test_post_scheduled_at(app, user, run):
|
||||
text = str(uuid.uuid4())
|
||||
scheduled_at = datetime.now(timezone.utc).replace(microsecond=0) + timedelta(minutes=10)
|
||||
|
||||
result = run(cli.post.post, text, "--scheduled-at", scheduled_at.isoformat())
|
||||
assert result.exit_code == 0
|
||||
|
||||
assert "Toot scheduled for" in result.stdout
|
||||
|
||||
statuses = api.scheduled_statuses(app, user)
|
||||
[status] = [s for s in statuses if s["params"]["text"] == text]
|
||||
assert datetime.strptime(status["scheduled_at"], "%Y-%m-%dT%H:%M:%S.%f%z") == scheduled_at
|
||||
|
||||
|
||||
def test_post_scheduled_at_error(run):
|
||||
result = run(cli.post.post, "foo", "--scheduled-at", "banana")
|
||||
assert result.exit_code == 1
|
||||
# Stupid error returned by mastodon
|
||||
assert result.stderr.strip() == "Error: Record invalid"
|
||||
|
||||
|
||||
def test_post_scheduled_in(app, user, run):
|
||||
text = str(uuid.uuid4())
|
||||
|
||||
variants = [
|
||||
("1 day", timedelta(days=1)),
|
||||
("1 day 6 hours", timedelta(days=1, hours=6)),
|
||||
("1 day 6 hours 13 minutes", timedelta(days=1, hours=6, minutes=13)),
|
||||
("1 day 6 hours 13 minutes 51 second", timedelta(days=1, hours=6, minutes=13, seconds=51)),
|
||||
("2d", timedelta(days=2)),
|
||||
("2d6h", timedelta(days=2, hours=6)),
|
||||
("2d6h13m", timedelta(days=2, hours=6, minutes=13)),
|
||||
("2d6h13m51s", timedelta(days=2, hours=6, minutes=13, seconds=51)),
|
||||
]
|
||||
|
||||
datetimes = []
|
||||
for scheduled_in, delta in variants:
|
||||
result = run(cli.post.post, text, "--scheduled-in", scheduled_in)
|
||||
assert result.exit_code == 0
|
||||
|
||||
dttm = datetime.utcnow() + delta
|
||||
assert result.stdout.startswith(f"Toot scheduled for: {str(dttm)[:16]}")
|
||||
datetimes.append(dttm)
|
||||
|
||||
scheduled = api.scheduled_statuses(app, user)
|
||||
scheduled = [s for s in scheduled if s["params"]["text"] == text]
|
||||
scheduled = sorted(scheduled, key=lambda s: s["scheduled_at"])
|
||||
assert len(scheduled) == 8
|
||||
|
||||
for expected, status in zip(datetimes, scheduled):
|
||||
actual = datetime.strptime(status["scheduled_at"], "%Y-%m-%dT%H:%M:%S.%fZ")
|
||||
delta = expected - actual
|
||||
assert delta.total_seconds() < 5
|
||||
|
||||
|
||||
def test_post_scheduled_in_invalid_duration(run):
|
||||
result = run(cli.post.post, "foo", "--scheduled-in", "banana")
|
||||
assert result.exit_code == 2
|
||||
assert "Invalid duration: banana" in result.stderr
|
||||
|
||||
|
||||
def test_post_scheduled_in_empty_duration(run):
|
||||
result = run(cli.post.post, "foo", "--scheduled-in", "0m")
|
||||
assert result.exit_code == 2
|
||||
assert "Empty duration" in result.stderr
|
||||
|
||||
|
||||
def test_post_poll(app, user, run):
|
||||
text = str(uuid.uuid4())
|
||||
|
||||
result = run(
|
||||
cli.post.post, text,
|
||||
"--poll-option", "foo",
|
||||
"--poll-option", "bar",
|
||||
"--poll-option", "baz",
|
||||
"--poll-option", "qux",
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
status_id = posted_status_id(result.stdout)
|
||||
|
||||
status = api.fetch_status(app, user, status_id).json()
|
||||
assert status["poll"]["expired"] is False
|
||||
assert status["poll"]["multiple"] is False
|
||||
assert status["poll"]["options"] == [
|
||||
{"title": "foo", "votes_count": 0},
|
||||
{"title": "bar", "votes_count": 0},
|
||||
{"title": "baz", "votes_count": 0},
|
||||
{"title": "qux", "votes_count": 0}
|
||||
]
|
||||
|
||||
# Test expires_at is 24h by default
|
||||
actual = datetime.strptime(status["poll"]["expires_at"], "%Y-%m-%dT%H:%M:%S.%f%z")
|
||||
expected = datetime.now(timezone.utc) + timedelta(days=1)
|
||||
delta = actual - expected
|
||||
assert delta.total_seconds() < 5
|
||||
|
||||
|
||||
def test_post_poll_multiple(app, user, run):
|
||||
text = str(uuid.uuid4())
|
||||
|
||||
result = run(
|
||||
cli.post.post, text,
|
||||
"--poll-option", "foo",
|
||||
"--poll-option", "bar",
|
||||
"--poll-multiple"
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
status_id = posted_status_id(result.stdout)
|
||||
status = api.fetch_status(app, user, status_id).json()
|
||||
assert status["poll"]["multiple"] is True
|
||||
|
||||
|
||||
def test_post_poll_expires_in(app, user, run):
|
||||
text = str(uuid.uuid4())
|
||||
|
||||
result = run(
|
||||
cli.post.post, text,
|
||||
"--poll-option", "foo",
|
||||
"--poll-option", "bar",
|
||||
"--poll-expires-in", "8h",
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
status_id = posted_status_id(result.stdout)
|
||||
|
||||
status = api.fetch_status(app, user, status_id).json()
|
||||
actual = datetime.strptime(status["poll"]["expires_at"], "%Y-%m-%dT%H:%M:%S.%f%z")
|
||||
expected = datetime.now(timezone.utc) + timedelta(hours=8)
|
||||
delta = actual - expected
|
||||
assert delta.total_seconds() < 5
|
||||
|
||||
|
||||
def test_post_poll_hide_totals(app, user, run):
|
||||
text = str(uuid.uuid4())
|
||||
|
||||
result = run(
|
||||
cli.post.post, text,
|
||||
"--poll-option", "foo",
|
||||
"--poll-option", "bar",
|
||||
"--poll-hide-totals"
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
status_id = posted_status_id(result.stdout)
|
||||
|
||||
status = api.fetch_status(app, user, status_id).json()
|
||||
|
||||
# votes_count is None when totals are hidden
|
||||
assert status["poll"]["options"] == [
|
||||
{"title": "foo", "votes_count": None},
|
||||
{"title": "bar", "votes_count": None},
|
||||
]
|
||||
|
||||
|
||||
def test_post_language(app, user, run):
|
||||
result = run(cli.post.post, "test", "--language", "hr")
|
||||
assert result.exit_code == 0
|
||||
|
||||
status_id = posted_status_id(result.stdout)
|
||||
status = api.fetch_status(app, user, status_id).json()
|
||||
assert status["language"] == "hr"
|
||||
|
||||
result = run(cli.post.post, "test", "--language", "zh")
|
||||
assert result.exit_code == 0
|
||||
|
||||
status_id = posted_status_id(result.stdout)
|
||||
status = api.fetch_status(app, user, status_id).json()
|
||||
assert status["language"] == "zh"
|
||||
|
||||
|
||||
def test_post_language_error(run):
|
||||
result = run(cli.post.post, "test", "--language", "banana")
|
||||
assert result.exit_code == 2
|
||||
assert "Language should be a two letter abbreviation." in result.stderr
|
||||
|
||||
|
||||
def test_media_thumbnail(app, user, run):
|
||||
video_path = path.join(ASSETS_DIR, "small.webm")
|
||||
thumbnail_path = path.join(ASSETS_DIR, "test1.png")
|
||||
|
||||
result = run(
|
||||
cli.post.post,
|
||||
"--media", video_path,
|
||||
"--thumbnail", thumbnail_path,
|
||||
"--description", "foo",
|
||||
"some text"
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
status_id = posted_status_id(result.stdout)
|
||||
status = api.fetch_status(app, user, status_id).json()
|
||||
[media] = status["media_attachments"]
|
||||
|
||||
assert media["description"] == "foo"
|
||||
assert media["type"] == "video"
|
||||
assert media["url"].endswith(".mp4")
|
||||
assert media["preview_url"].endswith(".png")
|
||||
|
||||
# Video properties
|
||||
assert int(media["meta"]["original"]["duration"]) == 5
|
||||
assert media["meta"]["original"]["height"] == 320
|
||||
assert media["meta"]["original"]["width"] == 560
|
||||
|
||||
# Thumbnail properties
|
||||
assert media["meta"]["small"]["height"] == 50
|
||||
assert media["meta"]["small"]["width"] == 50
|
||||
|
||||
|
||||
def test_media_attachments(app, user, run):
|
||||
path1 = path.join(ASSETS_DIR, "test1.png")
|
||||
path2 = path.join(ASSETS_DIR, "test2.png")
|
||||
path3 = path.join(ASSETS_DIR, "test3.png")
|
||||
path4 = path.join(ASSETS_DIR, "test4.png")
|
||||
|
||||
result = run(
|
||||
cli.post.post,
|
||||
"--media", path1,
|
||||
"--media", path2,
|
||||
"--media", path3,
|
||||
"--media", path4,
|
||||
"--description", "Test 1",
|
||||
"--description", "Test 2",
|
||||
"--description", "Test 3",
|
||||
"--description", "Test 4",
|
||||
"some text"
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
status_id = posted_status_id(result.stdout)
|
||||
status = api.fetch_status(app, user, status_id).json()
|
||||
|
||||
[a1, a2, a3, a4] = status["media_attachments"]
|
||||
|
||||
# Pleroma doesn't send metadata
|
||||
if "meta" in a1:
|
||||
assert a1["meta"]["original"]["size"] == "50x50"
|
||||
assert a2["meta"]["original"]["size"] == "50x60"
|
||||
assert a3["meta"]["original"]["size"] == "50x70"
|
||||
assert a4["meta"]["original"]["size"] == "50x80"
|
||||
|
||||
assert a1["description"] == "Test 1"
|
||||
assert a2["description"] == "Test 2"
|
||||
assert a3["description"] == "Test 3"
|
||||
assert a4["description"] == "Test 4"
|
||||
|
||||
|
||||
def test_too_many_media(run):
|
||||
m = path.join(ASSETS_DIR, "test1.png")
|
||||
result = run(cli.post.post, "-m", m, "-m", m, "-m", m, "-m", m, "-m", m)
|
||||
assert result.exit_code == 1
|
||||
assert result.stderr.strip() == "Error: Cannot attach more than 4 files."
|
||||
|
||||
|
||||
@mock.patch("toot.utils.multiline_input")
|
||||
@mock.patch("sys.stdin.read")
|
||||
def test_media_attachment_without_text(mock_read, mock_ml, app, user, run):
|
||||
# No status from stdin or readline
|
||||
mock_read.return_value = ""
|
||||
mock_ml.return_value = ""
|
||||
|
||||
media_path = path.join(ASSETS_DIR, "test1.png")
|
||||
|
||||
result = run(cli.post.post, "--media", media_path)
|
||||
assert result.exit_code == 0
|
||||
|
||||
status_id = posted_status_id(result.stdout)
|
||||
|
||||
status = api.fetch_status(app, user, status_id).json()
|
||||
assert status["content"] == ""
|
||||
|
||||
[attachment] = status["media_attachments"]
|
||||
assert not attachment["description"]
|
||||
|
||||
# Pleroma doesn't send metadata
|
||||
if "meta" in attachment:
|
||||
assert attachment["meta"]["original"]["size"] == "50x50"
|
||||
|
||||
|
||||
def test_reply_thread(app, user, friend, run):
|
||||
status = api.post_status(app, friend, "This is the status").json()
|
||||
|
||||
result = run(cli.post.post, "--reply-to", status["id"], "This is the reply")
|
||||
assert result.exit_code == 0
|
||||
|
||||
status_id = posted_status_id(result.stdout)
|
||||
reply = api.fetch_status(app, user, status_id).json()
|
||||
|
||||
assert reply["in_reply_to_id"] == status["id"]
|
||||
|
||||
result = run(cli.read.thread, status["id"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
[s1, s2] = [s.strip() for s in re.split(r"─+", result.stdout) if s.strip()]
|
||||
|
||||
assert "This is the status" in s1
|
||||
assert "This is the reply" in s2
|
||||
assert friend.username in s1
|
||||
assert user.username in s2
|
||||
assert status["id"] in s1
|
||||
assert reply["id"] in s2
|
203
tests/integration/test_read.py
Normal file
@ -0,0 +1,203 @@
|
||||
import json
|
||||
import re
|
||||
|
||||
from tests.integration.conftest import TOOT_TEST_BASE_URL
|
||||
from toot import api, cli
|
||||
from toot.entities import Account, Status, from_dict, from_dict_list
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
def test_instance_default(app, run):
|
||||
result = run(cli.read.instance)
|
||||
assert result.exit_code == 0
|
||||
|
||||
assert "Mastodon" in result.stdout
|
||||
assert app.instance in result.stdout
|
||||
assert "running Mastodon" in result.stdout
|
||||
|
||||
|
||||
def test_instance_with_url(app, run):
|
||||
result = run(cli.read.instance, TOOT_TEST_BASE_URL)
|
||||
assert result.exit_code == 0
|
||||
|
||||
assert "Mastodon" in result.stdout
|
||||
assert app.instance in result.stdout
|
||||
assert "running Mastodon" in result.stdout
|
||||
|
||||
|
||||
def test_instance_json(app, run):
|
||||
result = run(cli.read.instance, "--json")
|
||||
assert result.exit_code == 0
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
assert data["title"] is not None
|
||||
assert data["description"] is not None
|
||||
assert data["version"] is not None
|
||||
|
||||
|
||||
def test_instance_anon(app, run_anon, base_url):
|
||||
result = run_anon(cli.read.instance, base_url)
|
||||
assert result.exit_code == 0
|
||||
|
||||
assert "Mastodon" in result.stdout
|
||||
assert app.instance in result.stdout
|
||||
assert "running Mastodon" in result.stdout
|
||||
|
||||
# Need to specify the instance name when running anon
|
||||
result = run_anon(cli.read.instance)
|
||||
assert result.exit_code == 1
|
||||
assert result.stderr.strip() == "Error: INSTANCE argument not given and not logged in"
|
||||
|
||||
|
||||
def test_whoami(user, run):
|
||||
result = run(cli.read.whoami)
|
||||
assert result.exit_code == 0
|
||||
assert f"@{user.username}" in result.stdout
|
||||
|
||||
|
||||
def test_whoami_json(user, run):
|
||||
result = run(cli.read.whoami, "--json")
|
||||
assert result.exit_code == 0
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
account = from_dict(Account, data)
|
||||
assert account.username == user.username
|
||||
assert account.acct == user.username
|
||||
|
||||
|
||||
def test_whois(app, friend, run):
|
||||
variants = [
|
||||
friend.username,
|
||||
f"@{friend.username}",
|
||||
f"{friend.username}@{app.instance}",
|
||||
f"@{friend.username}@{app.instance}",
|
||||
]
|
||||
|
||||
for username in variants:
|
||||
result = run(cli.read.whois, username)
|
||||
assert result.exit_code == 0
|
||||
assert f"@{friend.username}" in result.stdout
|
||||
|
||||
|
||||
def test_whois_json(app, friend, run):
|
||||
result = run(cli.read.whois, friend.username, "--json")
|
||||
assert result.exit_code == 0
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
account = from_dict(Account, data)
|
||||
assert account.username == friend.username
|
||||
assert account.acct == friend.username
|
||||
|
||||
|
||||
def test_search_account(friend, run):
|
||||
result = run(cli.read.search, friend.username)
|
||||
assert result.exit_code == 0
|
||||
assert result.stdout.strip() == f"Accounts:\n* @{friend.username}"
|
||||
|
||||
|
||||
def test_search_account_json(friend, run):
|
||||
result = run(cli.read.search, friend.username, "--json")
|
||||
assert result.exit_code == 0
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
[account] = from_dict_list(Account, data["accounts"])
|
||||
assert account.acct == friend.username
|
||||
|
||||
|
||||
def test_search_hashtag(app, user, run):
|
||||
api.post_status(app, user, "#hashtag_x")
|
||||
api.post_status(app, user, "#hashtag_y")
|
||||
api.post_status(app, user, "#hashtag_z")
|
||||
|
||||
result = run(cli.read.search, "#hashtag")
|
||||
assert result.exit_code == 0
|
||||
assert result.stdout.strip() == "Hashtags:\n#hashtag_x, #hashtag_y, #hashtag_z"
|
||||
|
||||
|
||||
def test_search_hashtag_json(app, user, run):
|
||||
api.post_status(app, user, "#hashtag_x")
|
||||
api.post_status(app, user, "#hashtag_y")
|
||||
api.post_status(app, user, "#hashtag_z")
|
||||
|
||||
result = run(cli.read.search, "#hashtag", "--json")
|
||||
assert result.exit_code == 0
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
[h1, h2, h3] = sorted(data["hashtags"], key=lambda h: h["name"])
|
||||
|
||||
assert h1["name"] == "hashtag_x"
|
||||
assert h2["name"] == "hashtag_y"
|
||||
assert h3["name"] == "hashtag_z"
|
||||
|
||||
|
||||
def test_status(app, user, run):
|
||||
uuid = str(uuid4())
|
||||
status_id = api.post_status(app, user, uuid).json()["id"]
|
||||
|
||||
result = run(cli.read.status, status_id)
|
||||
assert result.exit_code == 0
|
||||
|
||||
out = result.stdout.strip()
|
||||
assert uuid in out
|
||||
assert user.username in out
|
||||
assert 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):
|
||||
uuid1 = str(uuid4())
|
||||
uuid2 = str(uuid4())
|
||||
uuid3 = str(uuid4())
|
||||
|
||||
s1 = api.post_status(app, user, uuid1).json()
|
||||
s2 = api.post_status(app, user, uuid2, in_reply_to_id=s1["id"]).json()
|
||||
s3 = api.post_status(app, user, uuid3, in_reply_to_id=s2["id"]).json()
|
||||
|
||||
for status in [s1, s2, s3]:
|
||||
result = run(cli.read.thread, status["id"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
bits = re.split(r"─+", result.stdout.strip())
|
||||
bits = [b for b in bits if b]
|
||||
|
||||
assert len(bits) == 3
|
||||
|
||||
assert s1["id"] in bits[0]
|
||||
assert s2["id"] in bits[1]
|
||||
assert s3["id"] in bits[2]
|
||||
|
||||
assert uuid1 in bits[0]
|
||||
assert uuid2 in bits[1]
|
||||
assert uuid3 in bits[2]
|
||||
|
||||
|
||||
def test_thread_json(app, user, run):
|
||||
uuid1 = str(uuid4())
|
||||
uuid2 = str(uuid4())
|
||||
uuid3 = str(uuid4())
|
||||
|
||||
s1 = api.post_status(app, user, uuid1).json()
|
||||
s2 = api.post_status(app, user, uuid2, in_reply_to_id=s1["id"]).json()
|
||||
s3 = api.post_status(app, user, uuid3, in_reply_to_id=s2["id"]).json()
|
||||
|
||||
result = run(cli.read.thread, s2["id"], "--json")
|
||||
assert result.exit_code == 0
|
||||
|
||||
result = json.loads(result.stdout)
|
||||
[ancestor] = [from_dict(Status, s) for s in result["ancestors"]]
|
||||
[descendent] = [from_dict(Status, s) for s in result["descendants"]]
|
||||
|
||||
assert ancestor.id == s1["id"]
|
||||
assert descendent.id == s3["id"]
|
200
tests/integration/test_status.py
Normal file
@ -0,0 +1,200 @@
|
||||
import json
|
||||
import pytest
|
||||
|
||||
from tests.utils import run_with_retries
|
||||
from toot import api, cli
|
||||
from toot.exceptions import NotFoundError
|
||||
|
||||
|
||||
def test_delete(app, user, run):
|
||||
status = api.post_status(app, user, "foo").json()
|
||||
|
||||
result = run(cli.statuses.delete, status["id"])
|
||||
assert result.exit_code == 0
|
||||
assert result.stdout.strip() == "✓ Status deleted"
|
||||
|
||||
with pytest.raises(NotFoundError):
|
||||
api.fetch_status(app, user, status["id"])
|
||||
|
||||
|
||||
def test_delete_json(app, user, run):
|
||||
status = api.post_status(app, user, "foo").json()
|
||||
|
||||
result = run(cli.statuses.delete, status["id"], "--json")
|
||||
assert result.exit_code == 0
|
||||
|
||||
out = result.stdout
|
||||
result = json.loads(out)
|
||||
assert result["id"] == status["id"]
|
||||
|
||||
with pytest.raises(NotFoundError):
|
||||
api.fetch_status(app, user, status["id"])
|
||||
|
||||
|
||||
def test_favourite(app, user, run):
|
||||
status = api.post_status(app, user, "foo").json()
|
||||
assert not status["favourited"]
|
||||
|
||||
result = run(cli.statuses.favourite, status["id"])
|
||||
assert result.exit_code == 0
|
||||
assert result.stdout.strip() == "✓ Status favourited"
|
||||
|
||||
status = api.fetch_status(app, user, status["id"]).json()
|
||||
assert status["favourited"]
|
||||
|
||||
result = run(cli.statuses.unfavourite, status["id"])
|
||||
assert result.exit_code == 0
|
||||
assert result.stdout.strip() == "✓ Status unfavourited"
|
||||
|
||||
def test_favourited():
|
||||
nonlocal status
|
||||
status = api.fetch_status(app, user, status["id"]).json()
|
||||
assert not status["favourited"]
|
||||
run_with_retries(test_favourited)
|
||||
|
||||
|
||||
def test_favourite_json(app, user, run):
|
||||
status = api.post_status(app, user, "foo").json()
|
||||
assert not status["favourited"]
|
||||
|
||||
result = run(cli.statuses.favourite, status["id"], "--json")
|
||||
assert result.exit_code == 0
|
||||
|
||||
result = json.loads(result.stdout)
|
||||
assert result["id"] == status["id"]
|
||||
assert result["favourited"] is True
|
||||
|
||||
result = run(cli.statuses.unfavourite, status["id"], "--json")
|
||||
assert result.exit_code == 0
|
||||
|
||||
result = json.loads(result.stdout)
|
||||
assert result["id"] == status["id"]
|
||||
assert result["favourited"] is False
|
||||
|
||||
|
||||
def test_reblog(app, user, run):
|
||||
status = api.post_status(app, user, "foo").json()
|
||||
assert not status["reblogged"]
|
||||
|
||||
result = run(cli.statuses.reblogged_by, status["id"])
|
||||
assert result.exit_code == 0
|
||||
assert result.stdout.strip() == "This status is not reblogged by anyone"
|
||||
|
||||
result = run(cli.statuses.reblog, status["id"])
|
||||
assert result.exit_code == 0
|
||||
assert result.stdout.strip() == "✓ Status reblogged"
|
||||
|
||||
status = api.fetch_status(app, user, status["id"]).json()
|
||||
assert status["reblogged"]
|
||||
|
||||
result = run(cli.statuses.reblogged_by, status["id"])
|
||||
assert result.exit_code == 0
|
||||
assert user.username in result.stdout
|
||||
|
||||
result = run(cli.statuses.unreblog, status["id"])
|
||||
assert result.exit_code == 0
|
||||
assert result.stdout.strip() == "✓ Status unreblogged"
|
||||
|
||||
status = api.fetch_status(app, user, status["id"]).json()
|
||||
assert not status["reblogged"]
|
||||
|
||||
|
||||
def test_reblog_json(app, user, run):
|
||||
status = api.post_status(app, user, "foo").json()
|
||||
assert not status["reblogged"]
|
||||
|
||||
result = run(cli.statuses.reblog, status["id"], "--json")
|
||||
assert result.exit_code == 0
|
||||
|
||||
result = json.loads(result.stdout)
|
||||
assert result["reblogged"] is True
|
||||
assert result["reblog"]["id"] == status["id"]
|
||||
|
||||
result = run(cli.statuses.reblogged_by, status["id"], "--json")
|
||||
assert result.exit_code == 0
|
||||
|
||||
[reblog] = json.loads(result.stdout)
|
||||
assert reblog["acct"] == user.username
|
||||
|
||||
result = run(cli.statuses.unreblog, status["id"], "--json")
|
||||
assert result.exit_code == 0
|
||||
|
||||
result = json.loads(result.stdout)
|
||||
assert result["reblogged"] is False
|
||||
assert result["reblog"] is None
|
||||
|
||||
|
||||
def test_pin(app, user, run):
|
||||
status = api.post_status(app, user, "foo").json()
|
||||
assert not status["pinned"]
|
||||
|
||||
result = run(cli.statuses.pin, status["id"])
|
||||
assert result.exit_code == 0
|
||||
assert result.stdout.strip() == "✓ Status pinned"
|
||||
|
||||
status = api.fetch_status(app, user, status["id"]).json()
|
||||
assert status["pinned"]
|
||||
|
||||
result = run(cli.statuses.unpin, status["id"])
|
||||
assert result.exit_code == 0
|
||||
assert result.stdout.strip() == "✓ Status unpinned"
|
||||
|
||||
status = api.fetch_status(app, user, status["id"]).json()
|
||||
assert not status["pinned"]
|
||||
|
||||
|
||||
def test_pin_json(app, user, run):
|
||||
status = api.post_status(app, user, "foo").json()
|
||||
assert not status["pinned"]
|
||||
|
||||
result = run(cli.statuses.pin, status["id"], "--json")
|
||||
assert result.exit_code == 0
|
||||
|
||||
result = json.loads(result.stdout)
|
||||
assert result["pinned"] is True
|
||||
assert result["id"] == status["id"]
|
||||
|
||||
result = run(cli.statuses.unpin, status["id"], "--json")
|
||||
assert result.exit_code == 0
|
||||
|
||||
result = json.loads(result.stdout)
|
||||
assert result["pinned"] is False
|
||||
assert result["id"] == status["id"]
|
||||
|
||||
|
||||
def test_bookmark(app, user, run):
|
||||
status = api.post_status(app, user, "foo").json()
|
||||
assert not status["bookmarked"]
|
||||
|
||||
result = run(cli.statuses.bookmark, status["id"])
|
||||
assert result.exit_code == 0
|
||||
assert result.stdout.strip() == "✓ Status bookmarked"
|
||||
|
||||
status = api.fetch_status(app, user, status["id"]).json()
|
||||
assert status["bookmarked"]
|
||||
|
||||
result = run(cli.statuses.unbookmark, status["id"])
|
||||
assert result.exit_code == 0
|
||||
assert result.stdout.strip() == "✓ Status unbookmarked"
|
||||
|
||||
status = api.fetch_status(app, user, status["id"]).json()
|
||||
assert not status["bookmarked"]
|
||||
|
||||
|
||||
def test_bookmark_json(app, user, run):
|
||||
status = api.post_status(app, user, "foo").json()
|
||||
assert not status["bookmarked"]
|
||||
|
||||
result = run(cli.statuses.bookmark, status["id"], "--json")
|
||||
assert result.exit_code == 0
|
||||
|
||||
result = json.loads(result.stdout)
|
||||
assert result["id"] == status["id"]
|
||||
assert result["bookmarked"] is True
|
||||
|
||||
result = run(cli.statuses.unbookmark, status["id"], "--json")
|
||||
assert result.exit_code == 0
|
||||
|
||||
result = json.loads(result.stdout)
|
||||
assert result["id"] == status["id"]
|
||||
assert result["bookmarked"] is False
|
163
tests/integration/test_tags.py
Normal file
@ -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))
|
196
tests/integration/test_timelines.py
Normal file
@ -0,0 +1,196 @@
|
||||
import pytest
|
||||
|
||||
from uuid import uuid4
|
||||
from tests.utils import run_with_retries
|
||||
|
||||
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 overridden 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")
|
||||
|
||||
# Home timeline
|
||||
def test_home():
|
||||
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
|
||||
run_with_retries(test_home)
|
||||
|
||||
# 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)
|
||||
|
||||
def test_notifications():
|
||||
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
|
||||
run_with_retries(test_notifications)
|
||||
|
||||
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
@ -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('bigfish.software')
|
||||
|
||||
mock_post.assert_called_once_with('https://bigfish.software/api/v1/apps', json={
|
||||
'website': CLIENT_WEBSITE,
|
||||
'client_name': CLIENT_NAME,
|
||||
'scopes': SCOPES,
|
||||
'redirect_uris': 'urn:ietf:wg:oauth:2.0:oob',
|
||||
})
|
||||
|
||||
|
||||
@mock.patch('toot.http.anon_post')
|
||||
def test_login(mock_post):
|
||||
app = App('bigfish.software', 'https://bigfish.software', 'foo', 'bar')
|
||||
|
||||
data = {
|
||||
'grant_type': 'password',
|
||||
'client_id': app.client_id,
|
||||
'client_secret': app.client_secret,
|
||||
'username': 'user',
|
||||
'password': 'pass',
|
||||
'scope': SCOPES,
|
||||
}
|
||||
|
||||
mock_post.return_value = MockResponse({
|
||||
'token_type': 'bearer',
|
||||
'scope': 'read write follow',
|
||||
'access_token': 'xxx',
|
||||
'created_at': 1492523699
|
||||
})
|
||||
|
||||
login(app, 'user', 'pass')
|
||||
|
||||
mock_post.assert_called_once_with(
|
||||
'https://bigfish.software/oauth/token', data=data, allow_redirects=False)
|
||||
|
||||
|
||||
@mock.patch('toot.http.anon_post')
|
||||
def test_login_failed(mock_post):
|
||||
app = App('bigfish.software', 'https://bigfish.software', 'foo', 'bar')
|
||||
|
||||
data = {
|
||||
'grant_type': 'password',
|
||||
'client_id': app.client_id,
|
||||
'client_secret': app.client_secret,
|
||||
'username': 'user',
|
||||
'password': 'pass',
|
||||
'scope': SCOPES,
|
||||
}
|
||||
|
||||
mock_post.return_value = MockResponse(is_redirect=True)
|
||||
|
||||
with pytest.raises(AuthenticationError):
|
||||
login(app, 'user', 'pass')
|
||||
|
||||
mock_post.assert_called_once_with(
|
||||
'https://bigfish.software/oauth/token', data=data, allow_redirects=False)
|
@ -1,58 +0,0 @@
|
||||
from toot import App, User, api, config, auth
|
||||
from tests.utils import retval
|
||||
|
||||
|
||||
def test_register_app(monkeypatch):
|
||||
app_data = {'id': 100, 'client_id': 'cid', 'client_secret': 'cs'}
|
||||
|
||||
def assert_app(app):
|
||||
assert isinstance(app, App)
|
||||
assert app.instance == "foo.bar"
|
||||
assert app.base_url == "https://foo.bar"
|
||||
assert app.client_id == "cid"
|
||||
assert app.client_secret == "cs"
|
||||
|
||||
monkeypatch.setattr(api, 'create_app', retval(app_data))
|
||||
monkeypatch.setattr(api, 'get_instance', retval({"title": "foo", "version": "1"}))
|
||||
monkeypatch.setattr(config, 'save_app', assert_app)
|
||||
|
||||
app = auth.register_app("foo.bar")
|
||||
assert_app(app)
|
||||
|
||||
|
||||
def test_create_app_from_config(monkeypatch):
|
||||
"""When there is saved config, it's returned"""
|
||||
monkeypatch.setattr(config, 'load_app', retval("loaded app"))
|
||||
app = auth.create_app_interactive("bezdomni.net")
|
||||
assert app == 'loaded app'
|
||||
|
||||
|
||||
def test_create_app_registered(monkeypatch):
|
||||
"""When there is no saved config, a new app is registered"""
|
||||
monkeypatch.setattr(config, 'load_app', retval(None))
|
||||
monkeypatch.setattr(auth, 'register_app', retval("registered app"))
|
||||
|
||||
app = auth.create_app_interactive("bezdomni.net")
|
||||
assert app == 'registered app'
|
||||
|
||||
|
||||
def test_create_user(monkeypatch):
|
||||
app = App(4, 5, 6, 7)
|
||||
|
||||
def assert_user(user, activate=True):
|
||||
assert activate
|
||||
assert isinstance(user, User)
|
||||
assert user.instance == app.instance
|
||||
assert user.username == "foo"
|
||||
assert user.access_token == "abc"
|
||||
|
||||
monkeypatch.setattr(config, 'save_user', assert_user)
|
||||
monkeypatch.setattr(api, 'verify_credentials', lambda x, y: {"username": "foo"})
|
||||
|
||||
user = auth.create_user(app, 'abc')
|
||||
|
||||
assert_user(user)
|
||||
|
||||
#
|
||||
# TODO: figure out how to mock input so the rest can be tested
|
||||
#
|
@ -60,6 +60,7 @@ def test_extract_active_when_no_active_user(sample_config):
|
||||
|
||||
|
||||
def test_save_app(sample_config):
|
||||
pytest.skip("TODO: fix mocking")
|
||||
app = App('xxx.yyy', 2, 3, 4)
|
||||
app2 = App('moo.foo', 5, 6, 7)
|
||||
|
||||
@ -106,6 +107,7 @@ def test_save_app(sample_config):
|
||||
|
||||
|
||||
def test_delete_app(sample_config):
|
||||
pytest.skip("TODO: fix mocking")
|
||||
app = App('foo.social', 2, 3, 4)
|
||||
|
||||
app_count = len(sample_config['apps'])
|
||||
|
@ -1,670 +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 🎸',
|
||||
'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', None)
|
||||
|
||||
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': {
|
||||
'account': {
|
||||
'display_name': 'Johnny Cash',
|
||||
'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', None)
|
||||
|
||||
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 "↻ Reblogged @jc" in lines[-3]
|
||||
|
||||
assert err == ""
|
||||
|
||||
|
||||
@mock.patch('toot.http.get')
|
||||
def test_thread(mock_get, monkeypatch, capsys):
|
||||
mock_get.side_effect = [
|
||||
MockResponse({
|
||||
'id': '111111111111111111',
|
||||
'account': {
|
||||
'display_name': 'Frank Zappa',
|
||||
'acct': 'fz'
|
||||
},
|
||||
'created_at': '2017-04-12T15:53:18.174Z',
|
||||
'content': "my response in the middle",
|
||||
'reblog': None,
|
||||
'in_reply_to_id': '111111111111111110',
|
||||
'media_attachments': [],
|
||||
}),
|
||||
MockResponse({
|
||||
'ancestors': [{
|
||||
'id': '111111111111111110',
|
||||
'account': {
|
||||
'display_name': 'Frank Zappa',
|
||||
'acct': 'fz'
|
||||
},
|
||||
'created_at': '2017-04-12T15:53:18.174Z',
|
||||
'content': "original content",
|
||||
'media_attachments': [],
|
||||
'reblog': None,
|
||||
'in_reply_to_id': None}],
|
||||
'descendants': [{
|
||||
'id': '111111111111111112',
|
||||
'account': {
|
||||
'display_name': 'Frank Zappa',
|
||||
'acct': 'fz'
|
||||
},
|
||||
'created_at': '2017-04-12T15:53:18.174Z',
|
||||
'content': "response message",
|
||||
'media_attachments': [],
|
||||
'reblog': None,
|
||||
'in_reply_to_id': '111111111111111111'}],
|
||||
}),
|
||||
]
|
||||
|
||||
console.run_command(app, user, 'thread', ['111111111111111111'])
|
||||
|
||||
calls = [
|
||||
mock.call(app, user, '/api/v1/statuses/111111111111111111'),
|
||||
mock.call(app, user, '/api/v1/statuses/111111111111111111/context'),
|
||||
]
|
||||
mock_get.assert_has_calls(calls, any_order=False)
|
||||
|
||||
out, err = capsys.readouterr()
|
||||
|
||||
assert not err
|
||||
|
||||
# Display order
|
||||
assert out.index('original content') < out.index('my response in the middle')
|
||||
assert out.index('my response in the middle') < out.index('response message')
|
||||
|
||||
assert "original content" in out
|
||||
assert "my response in the middle" in out
|
||||
assert "response message" in out
|
||||
assert "Frank Zappa" in out
|
||||
assert "@fz" in out
|
||||
assert "111111111111111111" in out
|
||||
assert "In reply to" in out
|
||||
|
||||
@mock.patch('toot.http.get')
|
||||
def test_reblogged_by(mock_get, monkeypatch, capsys):
|
||||
mock_get.return_value = MockResponse([{
|
||||
'display_name': 'Terry Bozzio',
|
||||
'acct': 'bozzio@drummers.social',
|
||||
}, {
|
||||
'display_name': 'Dweezil',
|
||||
'acct': 'dweezil@zappafamily.social',
|
||||
}])
|
||||
|
||||
console.run_command(app, user, 'reblogged_by', ['111111111111111111'])
|
||||
|
||||
calls = [
|
||||
mock.call(app, user, '/api/v1/statuses/111111111111111111/reblogged_by'),
|
||||
]
|
||||
mock_get.assert_has_calls(calls, any_order=False)
|
||||
|
||||
out, err = capsys.readouterr()
|
||||
|
||||
# Display order
|
||||
expected = "\n".join([
|
||||
"Terry Bozzio",
|
||||
" @bozzio@drummers.social",
|
||||
"Dweezil",
|
||||
" @dweezil@zappafamily.social",
|
||||
"",
|
||||
])
|
||||
assert out == expected
|
||||
|
||||
|
||||
@mock.patch('toot.http.post')
|
||||
def test_upload(mock_post, capsys):
|
||||
mock_post.return_value = MockResponse({
|
||||
'id': 123,
|
||||
'url': 'https://bigfish.software/123/456',
|
||||
'preview_url': 'https://bigfish.software/789/012',
|
||||
'url': 'https://bigfish.software/345/678',
|
||||
'type': 'image',
|
||||
})
|
||||
|
||||
console.run_command(app, user, 'upload', [__file__])
|
||||
|
||||
mock_post.call_count == 1
|
||||
|
||||
args, kwargs = http.post.call_args
|
||||
assert args == (app, user, '/api/v1/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_search(mock_get, capsys):
|
||||
mock_get.return_value = MockResponse({
|
||||
'hashtags': [
|
||||
{
|
||||
'history': [],
|
||||
'name': 'foo',
|
||||
'url': 'https://mastodon.social/tags/foo'
|
||||
},
|
||||
{
|
||||
'history': [],
|
||||
'name': 'bar',
|
||||
'url': 'https://mastodon.social/tags/bar'
|
||||
},
|
||||
{
|
||||
'history': [],
|
||||
'name': 'baz',
|
||||
'url': 'https://mastodon.social/tags/baz'
|
||||
},
|
||||
],
|
||||
'accounts': [{
|
||||
'acct': 'thequeen',
|
||||
'display_name': 'Freddy Mercury'
|
||||
}, {
|
||||
'acct': 'thequeen@other.instance',
|
||||
'display_name': 'Mercury Freddy'
|
||||
}],
|
||||
'statuses': [],
|
||||
})
|
||||
|
||||
console.run_command(app, user, 'search', ['freddy'])
|
||||
|
||||
mock_get.assert_called_once_with(app, user, '/api/v2/search', {
|
||||
'q': 'freddy',
|
||||
'type': None,
|
||||
'resolve': False,
|
||||
})
|
||||
|
||||
out, err = capsys.readouterr()
|
||||
assert "Hashtags:\n#foo, #bar, #baz" in out
|
||||
assert "Accounts:" in out
|
||||
assert "@thequeen Freddy Mercury" in out
|
||||
assert "@thequeen@other.instance Mercury Freddy" in out
|
||||
|
||||
|
||||
@mock.patch('toot.http.post')
|
||||
@mock.patch('toot.http.get')
|
||||
def test_follow(mock_get, mock_post, capsys):
|
||||
mock_get.return_value = MockResponse({
|
||||
"accounts": [
|
||||
{"id": 123, "acct": "blixa@other.acc"},
|
||||
{"id": 321, "acct": "blixa"},
|
||||
]
|
||||
})
|
||||
mock_post.return_value = MockResponse()
|
||||
|
||||
console.run_command(app, user, 'follow', ['blixa'])
|
||||
|
||||
mock_get.assert_called_once_with(app, user, '/api/v2/search', {'q': 'blixa', 'type': 'accounts', 'resolve': True})
|
||||
mock_post.assert_called_once_with(app, user, '/api/v1/accounts/321/follow')
|
||||
|
||||
out, err = capsys.readouterr()
|
||||
assert "You are now following blixa" in out
|
||||
|
||||
|
||||
@mock.patch('toot.http.post')
|
||||
@mock.patch('toot.http.get')
|
||||
def test_follow_case_insensitive(mock_get, mock_post, capsys):
|
||||
mock_get.return_value = MockResponse({
|
||||
"accounts": [
|
||||
{"id": 123, "acct": "blixa@other.acc"},
|
||||
{"id": 321, "acct": "blixa"},
|
||||
]
|
||||
})
|
||||
mock_post.return_value = MockResponse()
|
||||
|
||||
console.run_command(app, user, 'follow', ['bLiXa@oThEr.aCc'])
|
||||
|
||||
mock_get.assert_called_once_with(app, user, '/api/v2/search', {'q': 'bLiXa@oThEr.aCc', 'type': 'accounts', 'resolve': True})
|
||||
mock_post.assert_called_once_with(app, user, '/api/v1/accounts/123/follow')
|
||||
|
||||
out, err = capsys.readouterr()
|
||||
assert "You are now following bLiXa@oThEr.aCc" in out
|
||||
|
||||
|
||||
@mock.patch('toot.http.get')
|
||||
def test_follow_not_found(mock_get, capsys):
|
||||
mock_get.return_value = MockResponse({"accounts": []})
|
||||
|
||||
with pytest.raises(ConsoleError) as ex:
|
||||
console.run_command(app, user, 'follow', ['blixa'])
|
||||
|
||||
mock_get.assert_called_once_with(app, user, '/api/v2/search', {'q': 'blixa', 'type': 'accounts', 'resolve': True})
|
||||
assert "Account not found" == str(ex.value)
|
||||
|
||||
|
||||
@mock.patch('toot.http.post')
|
||||
@mock.patch('toot.http.get')
|
||||
def test_unfollow(mock_get, mock_post, capsys):
|
||||
mock_get.return_value = MockResponse({
|
||||
"accounts": [
|
||||
{'id': 123, 'acct': 'blixa@other.acc'},
|
||||
{'id': 321, 'acct': 'blixa'},
|
||||
]
|
||||
})
|
||||
|
||||
mock_post.return_value = MockResponse()
|
||||
|
||||
console.run_command(app, user, 'unfollow', ['blixa'])
|
||||
|
||||
mock_get.assert_called_once_with(app, user, '/api/v2/search', {'q': 'blixa', 'type': 'accounts', 'resolve': True})
|
||||
mock_post.assert_called_once_with(app, user, '/api/v1/accounts/321/unfollow')
|
||||
|
||||
out, err = capsys.readouterr()
|
||||
assert "You are no longer following blixa" in out
|
||||
|
||||
|
||||
@mock.patch('toot.http.get')
|
||||
def test_unfollow_not_found(mock_get, capsys):
|
||||
mock_get.return_value = MockResponse({"accounts": []})
|
||||
|
||||
with pytest.raises(ConsoleError) as ex:
|
||||
console.run_command(app, user, 'unfollow', ['blixa'])
|
||||
|
||||
mock_get.assert_called_once_with(app, user, '/api/v2/search', {'q': 'blixa', 'type': 'accounts', 'resolve': True})
|
||||
|
||||
assert "Account not found" == str(ex.value)
|
||||
|
||||
|
||||
@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'
|
||||
})
|
||||
|
||||
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,518 +0,0 @@
|
||||
"""
|
||||
This module contains integration tests meant to run against a test Mastodon instance.
|
||||
|
||||
You can set up a test instance locally by following this guide:
|
||||
https://docs.joinmastodon.org/dev/setup/
|
||||
|
||||
To enable integration tests, export the following environment variables to match
|
||||
your test server and database:
|
||||
|
||||
```
|
||||
export TOOT_TEST_HOSTNAME="localhost:3000"
|
||||
export TOOT_TEST_DATABASE_DSN="dbname=mastodon_development"
|
||||
```
|
||||
"""
|
||||
|
||||
import os
|
||||
import psycopg2
|
||||
import pytest
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from os import path
|
||||
from toot import CLIENT_NAME, CLIENT_WEBSITE, api, App, User
|
||||
from toot.console import run_command
|
||||
from toot.exceptions import ConsoleError, NotFoundError
|
||||
from toot.utils import get_text
|
||||
from unittest import mock
|
||||
|
||||
# Host name of a test instance to run integration tests against
|
||||
# DO NOT USE PUBLIC INSTANCES!!!
|
||||
HOSTNAME = os.getenv("TOOT_TEST_HOSTNAME")
|
||||
|
||||
# Mastodon database name, used to confirm user registration without having to click the link
|
||||
DATABASE_DSN = os.getenv("TOOT_TEST_DATABASE_DSN")
|
||||
|
||||
|
||||
if not HOSTNAME or not DATABASE_DSN:
|
||||
pytest.skip("Skipping integration tests", allow_module_level=True)
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def create_app():
|
||||
response = api.create_app(HOSTNAME, scheme="http")
|
||||
return App(HOSTNAME, f"http://{HOSTNAME}", response["client_id"], response["client_secret"])
|
||||
|
||||
|
||||
def register_account(app: App):
|
||||
username = str(uuid.uuid4())[-10:]
|
||||
email = f"{username}@example.com"
|
||||
|
||||
response = api.register_account(app, username, email, "password", "en")
|
||||
confirm_user(email)
|
||||
return User(app.instance, username, response["access_token"])
|
||||
|
||||
|
||||
def confirm_user(email):
|
||||
conn = psycopg2.connect(DATABASE_DSN)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("UPDATE users SET confirmed_at = now() WHERE email = %s;", (email,))
|
||||
conn.commit()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def app():
|
||||
return create_app()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def user(app):
|
||||
return register_account(app)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def friend(app):
|
||||
return register_account(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def run(app, user, capsys):
|
||||
def _run(command, *params, as_user=None):
|
||||
run_command(app, as_user or user, command, params or [])
|
||||
out, err = capsys.readouterr()
|
||||
assert err == ""
|
||||
return strip_ansi(out)
|
||||
return _run
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def run_anon(capsys):
|
||||
def _run(command, *params):
|
||||
run_command(None, None, command, params or [])
|
||||
out, err = capsys.readouterr()
|
||||
assert err == ""
|
||||
return strip_ansi(out)
|
||||
return _run
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_instance(app, run):
|
||||
out = run("instance", "--disable-https")
|
||||
assert "Mastodon" in out
|
||||
assert app.instance in out
|
||||
assert "running Mastodon" in out
|
||||
|
||||
|
||||
def test_instance_anon(app, run_anon):
|
||||
out = run_anon("instance", "--disable-https", HOSTNAME)
|
||||
assert "Mastodon" in out
|
||||
assert app.instance in out
|
||||
assert "running Mastodon" in out
|
||||
|
||||
# Need to specify the instance name when running anon
|
||||
with pytest.raises(ConsoleError) as exc:
|
||||
run_anon("instance")
|
||||
assert str(exc.value) == "Please specify instance name."
|
||||
|
||||
|
||||
def test_post(app, user, run):
|
||||
text = "i wish i was a #lumberjack"
|
||||
out = run("post", text)
|
||||
status_id = _posted_status_id(out)
|
||||
|
||||
status = api.fetch_status(app, user, status_id)
|
||||
assert text == get_text(status["content"])
|
||||
assert status["visibility"] == "public"
|
||||
assert status["sensitive"] is False
|
||||
assert status["spoiler_text"] == ""
|
||||
|
||||
# Pleroma doesn't return the application
|
||||
if status["application"]:
|
||||
assert status["application"]["name"] == CLIENT_NAME
|
||||
assert status["application"]["website"] == CLIENT_WEBSITE
|
||||
|
||||
|
||||
def test_post_visibility(app, user, run):
|
||||
for visibility in ["public", "unlisted", "private", "direct"]:
|
||||
out = run("post", "foo", "--visibility", visibility)
|
||||
status_id = _posted_status_id(out)
|
||||
status = api.fetch_status(app, user, status_id)
|
||||
assert status["visibility"] == visibility
|
||||
|
||||
|
||||
def test_post_scheduled_at(app, user, run):
|
||||
text = str(uuid.uuid4())
|
||||
scheduled_at = datetime.now(timezone.utc).replace(microsecond=0) + timedelta(minutes=10)
|
||||
|
||||
out = run("post", text, "--scheduled-at", scheduled_at.isoformat())
|
||||
assert "Toot scheduled for" in out
|
||||
|
||||
statuses = api.scheduled_statuses(app, user)
|
||||
[status] = [s for s in statuses if s["params"]["text"] == text]
|
||||
assert datetime.strptime(status["scheduled_at"], "%Y-%m-%dT%H:%M:%S.%f%z") == scheduled_at
|
||||
|
||||
|
||||
def test_post_scheduled_in(app, user, run):
|
||||
text = str(uuid.uuid4())
|
||||
|
||||
variants = [
|
||||
("1 day", timedelta(days=1)),
|
||||
("1 day 6 hours", timedelta(days=1, hours=6)),
|
||||
("1 day 6 hours 13 minutes", timedelta(days=1, hours=6, minutes=13)),
|
||||
("1 day 6 hours 13 minutes 51 second", timedelta(days=1, hours=6, minutes=13, seconds=51)),
|
||||
("2d", timedelta(days=2)),
|
||||
("2d6h", timedelta(days=2, hours=6)),
|
||||
("2d6h13m", timedelta(days=2, hours=6, minutes=13)),
|
||||
("2d6h13m51s", timedelta(days=2, hours=6, minutes=13, seconds=51)),
|
||||
]
|
||||
|
||||
datetimes = []
|
||||
for scheduled_in, delta in variants:
|
||||
out = run("post", text, "--scheduled-in", scheduled_in)
|
||||
dttm = datetime.utcnow() + delta
|
||||
assert out.startswith(f"Toot scheduled for: {str(dttm)[:16]}")
|
||||
datetimes.append(dttm)
|
||||
|
||||
scheduled = api.scheduled_statuses(app, user)
|
||||
scheduled = [s for s in scheduled if s["params"]["text"] == text]
|
||||
scheduled = sorted(scheduled, key=lambda s: s["scheduled_at"])
|
||||
assert len(scheduled) == 8
|
||||
|
||||
for expected, status in zip(datetimes, scheduled):
|
||||
actual = datetime.strptime(status["scheduled_at"], "%Y-%m-%dT%H:%M:%S.%fZ")
|
||||
delta = expected - actual
|
||||
assert delta.total_seconds() < 5
|
||||
|
||||
|
||||
def test_post_language(app, user, run):
|
||||
out = run("post", "test", "--language", "hr")
|
||||
status_id = _posted_status_id(out)
|
||||
status = api.fetch_status(app, user, status_id)
|
||||
assert status["language"] == "hr"
|
||||
|
||||
out = run("post", "test", "--language", "zh")
|
||||
status_id = _posted_status_id(out)
|
||||
status = api.fetch_status(app, user, status_id)
|
||||
assert status["language"] == "zh"
|
||||
|
||||
|
||||
def test_media_attachments(app, user, run):
|
||||
assets_dir = path.realpath(path.join(path.dirname(__file__), "assets"))
|
||||
|
||||
path1 = path.join(assets_dir, "test1.png")
|
||||
path2 = path.join(assets_dir, "test2.png")
|
||||
path3 = path.join(assets_dir, "test3.png")
|
||||
path4 = path.join(assets_dir, "test4.png")
|
||||
|
||||
out = run(
|
||||
"post",
|
||||
"--media", path1,
|
||||
"--media", path2,
|
||||
"--media", path3,
|
||||
"--media", path4,
|
||||
"--description", "Test 1",
|
||||
"--description", "Test 2",
|
||||
"--description", "Test 3",
|
||||
"--description", "Test 4",
|
||||
"some text"
|
||||
)
|
||||
|
||||
status_id = _posted_status_id(out)
|
||||
status = api.fetch_status(app, user, status_id)
|
||||
|
||||
[a1, a2, a3, a4] = status["media_attachments"]
|
||||
|
||||
# Pleroma doesn't send metadata
|
||||
if "meta" in a1:
|
||||
assert a1["meta"]["original"]["size"] == "50x50"
|
||||
assert a2["meta"]["original"]["size"] == "50x60"
|
||||
assert a3["meta"]["original"]["size"] == "50x70"
|
||||
assert a4["meta"]["original"]["size"] == "50x80"
|
||||
|
||||
assert a1["description"] == "Test 1"
|
||||
assert a2["description"] == "Test 2"
|
||||
assert a3["description"] == "Test 3"
|
||||
assert a4["description"] == "Test 4"
|
||||
|
||||
|
||||
@mock.patch("toot.utils.multiline_input")
|
||||
@mock.patch("sys.stdin.read")
|
||||
def test_media_attachment_without_text(mock_read, mock_ml, app, user, run):
|
||||
# No status from stdin or readline
|
||||
mock_read.return_value = ""
|
||||
mock_ml.return_value = ""
|
||||
|
||||
assets_dir = path.realpath(path.join(path.dirname(__file__), "assets"))
|
||||
media_path = path.join(assets_dir, "test1.png")
|
||||
|
||||
out = run("post", "--media", media_path)
|
||||
status_id = _posted_status_id(out)
|
||||
|
||||
status = api.fetch_status(app, user, status_id)
|
||||
assert status["content"] == ""
|
||||
|
||||
[attachment] = status["media_attachments"]
|
||||
assert not attachment["description"]
|
||||
|
||||
# Pleroma doesn't send metadata
|
||||
if "meta" in attachment:
|
||||
assert attachment["meta"]["original"]["size"] == "50x50"
|
||||
|
||||
|
||||
def test_delete_status(app, user, run):
|
||||
status = api.post_status(app, user, "foo")
|
||||
|
||||
out = run("delete", status["id"])
|
||||
assert out == "✓ Status deleted"
|
||||
|
||||
with pytest.raises(NotFoundError):
|
||||
api.fetch_status(app, user, status["id"])
|
||||
|
||||
|
||||
def test_reply_thread(app, user, friend, run):
|
||||
status = api.post_status(app, friend, "This is the status")
|
||||
|
||||
out = run("post", "--reply-to", status["id"], "This is the reply")
|
||||
status_id = _posted_status_id(out)
|
||||
reply = api.fetch_status(app, user, status_id)
|
||||
|
||||
assert reply["in_reply_to_id"] == status["id"]
|
||||
|
||||
out = run("thread", status["id"])
|
||||
[s1, s2] = [s.strip() for s in re.split(r"─+", out) if s.strip()]
|
||||
|
||||
assert "This is the status" in s1
|
||||
assert "This is the reply" in s2
|
||||
assert friend.username in s1
|
||||
assert user.username in s2
|
||||
assert status["id"] in s1
|
||||
assert reply["id"] in s2
|
||||
|
||||
|
||||
def test_favourite(app, user, run):
|
||||
status = api.post_status(app, user, "foo")
|
||||
assert not status["favourited"]
|
||||
|
||||
out = run("favourite", status["id"])
|
||||
assert out == "✓ Status favourited"
|
||||
|
||||
status = api.fetch_status(app, user, status["id"])
|
||||
assert status["favourited"]
|
||||
|
||||
out = run("unfavourite", status["id"])
|
||||
assert out == "✓ Status unfavourited"
|
||||
|
||||
# A short delay is required before the server returns new data
|
||||
time.sleep(0.1)
|
||||
|
||||
status = api.fetch_status(app, user, status["id"])
|
||||
assert not status["favourited"]
|
||||
|
||||
|
||||
def test_reblog(app, user, run):
|
||||
status = api.post_status(app, user, "foo")
|
||||
assert not status["reblogged"]
|
||||
|
||||
out = run("reblog", status["id"])
|
||||
assert out == "✓ Status reblogged"
|
||||
|
||||
status = api.fetch_status(app, user, status["id"])
|
||||
assert status["reblogged"]
|
||||
|
||||
out = run("reblogged_by", status["id"])
|
||||
assert out == f"@{user.username}"
|
||||
|
||||
out = run("unreblog", status["id"])
|
||||
assert out == "✓ Status unreblogged"
|
||||
|
||||
status = api.fetch_status(app, user, status["id"])
|
||||
assert not status["reblogged"]
|
||||
|
||||
|
||||
def test_pin(app, user, run):
|
||||
status = api.post_status(app, user, "foo")
|
||||
assert not status["pinned"]
|
||||
|
||||
out = run("pin", status["id"])
|
||||
assert out == "✓ Status pinned"
|
||||
|
||||
status = api.fetch_status(app, user, status["id"])
|
||||
assert status["pinned"]
|
||||
|
||||
out = run("unpin", status["id"])
|
||||
assert out == "✓ Status unpinned"
|
||||
|
||||
status = api.fetch_status(app, user, status["id"])
|
||||
assert not status["pinned"]
|
||||
|
||||
|
||||
def test_bookmark(app, user, run):
|
||||
status = api.post_status(app, user, "foo")
|
||||
assert not status["bookmarked"]
|
||||
|
||||
out = run("bookmark", status["id"])
|
||||
assert out == "✓ Status bookmarked"
|
||||
|
||||
status = api.fetch_status(app, user, status["id"])
|
||||
assert status["bookmarked"]
|
||||
|
||||
out = run("unbookmark", status["id"])
|
||||
assert out == "✓ Status unbookmarked"
|
||||
|
||||
status = api.fetch_status(app, user, status["id"])
|
||||
assert not status["bookmarked"]
|
||||
|
||||
|
||||
def test_whoami(user, run):
|
||||
out = run("whoami")
|
||||
# TODO: test other fields once updating account is supported
|
||||
assert f"@{user.username}" in out
|
||||
assert f"http://{HOSTNAME}/@{user.username}" in out
|
||||
|
||||
|
||||
def test_whois(app, friend, run):
|
||||
variants = [
|
||||
friend.username,
|
||||
f"@{friend.username}",
|
||||
f"{friend.username}@{app.instance}",
|
||||
f"@{friend.username}@{app.instance}",
|
||||
]
|
||||
|
||||
for username in variants:
|
||||
out = run("whois", username)
|
||||
assert f"@{friend.username}" in out
|
||||
assert f"http://{HOSTNAME}/@{friend.username}" in out
|
||||
|
||||
|
||||
def test_search_account(friend, run):
|
||||
out = run("search", friend.username)
|
||||
assert out == f"Accounts:\n* @{friend.username}"
|
||||
|
||||
|
||||
def test_search_hashtag(app, user, run):
|
||||
api.post_status(app, user, "#hashtag_x")
|
||||
api.post_status(app, user, "#hashtag_y")
|
||||
api.post_status(app, user, "#hashtag_z")
|
||||
|
||||
out = run("search", "#hashtag")
|
||||
assert out == "Hashtags:\n#hashtag_x, #hashtag_y, #hashtag_z"
|
||||
|
||||
|
||||
def test_follow(friend, run):
|
||||
out = run("follow", friend.username)
|
||||
assert out == f"✓ You are now following {friend.username}"
|
||||
|
||||
out = run("unfollow", friend.username)
|
||||
assert out == f"✓ You are no longer following {friend.username}"
|
||||
|
||||
|
||||
def test_follow_case_insensitive(friend, run):
|
||||
username = friend.username.upper()
|
||||
|
||||
out = run("follow", username)
|
||||
assert out == f"✓ You are now following {username}"
|
||||
|
||||
out = run("unfollow", username)
|
||||
assert out == f"✓ You are no longer following {username}"
|
||||
|
||||
|
||||
# TODO: improve testing stderr, catching exceptions is not optimal
|
||||
def test_follow_not_found(run):
|
||||
with pytest.raises(ConsoleError) as ex_info:
|
||||
run("follow", "banana")
|
||||
assert str(ex_info.value) == "Account not found"
|
||||
|
||||
|
||||
def test_mute(app, user, friend, run):
|
||||
out = run("mute", friend.username)
|
||||
assert out == f"✓ You have muted {friend.username}"
|
||||
|
||||
[muted_account] = api.get_muted_accounts(app, user)
|
||||
assert muted_account["acct"] == friend.username
|
||||
|
||||
out = run("unmute", friend.username)
|
||||
assert out == f"✓ {friend.username} is no longer muted"
|
||||
|
||||
assert api.get_muted_accounts(app, user) == []
|
||||
|
||||
|
||||
def test_block(app, user, friend, run):
|
||||
out = run("block", friend.username)
|
||||
assert out == f"✓ You are now blocking {friend.username}"
|
||||
|
||||
[blockd_account] = api.get_blocked_accounts(app, user)
|
||||
assert blockd_account["acct"] == friend.username
|
||||
|
||||
out = run("unblock", friend.username)
|
||||
assert out == f"✓ {friend.username} is no longer blocked"
|
||||
|
||||
assert api.get_blocked_accounts(app, user) == []
|
||||
|
||||
|
||||
def test_following_followers(user, friend, run):
|
||||
out = run("following", user.username)
|
||||
assert out == ""
|
||||
|
||||
run("follow", friend.username)
|
||||
|
||||
out = run("following", user.username)
|
||||
assert out == f"* @{friend.username}"
|
||||
|
||||
out = run("followers", friend.username)
|
||||
assert out == f"* @{user.username}"
|
||||
|
||||
|
||||
def test_tags(run):
|
||||
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 == "* #foo\thttp://localhost:3000/tags/foo"
|
||||
|
||||
out = run("tags_follow", "bar")
|
||||
assert out == "✓ You are now following #bar"
|
||||
|
||||
out = run("tags_followed")
|
||||
assert out == "\n".join([
|
||||
"* #bar\thttp://localhost:3000/tags/bar",
|
||||
"* #foo\thttp://localhost:3000/tags/foo",
|
||||
])
|
||||
|
||||
out = run("tags_unfollow", "foo")
|
||||
assert out == "✓ You are no longer following #foo"
|
||||
|
||||
out = run("tags_followed")
|
||||
assert out == "* #bar\thttp://localhost:3000/tags/bar"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Utils
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
strip_ansi_pattern = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
|
||||
|
||||
|
||||
def strip_ansi(string):
|
||||
return strip_ansi_pattern.sub("", string).strip()
|
||||
|
||||
|
||||
def _posted_status_id(out):
|
||||
pattern = re.compile(r"Toot posted: http://([^/]+)/([^/]+)/(.+)")
|
||||
match = re.search(pattern, out)
|
||||
assert match
|
||||
|
||||
host, _, status_id = match.groups()
|
||||
assert host == HOSTNAME
|
||||
|
||||
return status_id
|
@ -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,8 +1,9 @@
|
||||
from argparse import ArgumentTypeError
|
||||
import click
|
||||
import pytest
|
||||
|
||||
from toot.console import duration
|
||||
from toot.cli.validators import validate_duration
|
||||
from toot.wcstring import wc_wrap, trunc, pad, fit_text
|
||||
from toot.utils import urlencode_url
|
||||
|
||||
|
||||
def test_pad():
|
||||
@ -162,6 +163,9 @@ def test_wc_wrap_indented():
|
||||
|
||||
|
||||
def test_duration():
|
||||
def duration(value):
|
||||
return validate_duration(None, None, value)
|
||||
|
||||
# Long hand
|
||||
assert duration("1 second") == 1
|
||||
assert duration("1 seconds") == 1
|
||||
@ -189,15 +193,20 @@ def test_duration():
|
||||
assert duration("5d 10h 3m 1s") == 5 * 86400 + 10 * 3600 + 3 * 60 + 1
|
||||
assert duration("5d10h3m1s") == 5 * 86400 + 10 * 3600 + 3 * 60 + 1
|
||||
|
||||
with pytest.raises(ArgumentTypeError):
|
||||
with pytest.raises(click.BadParameter):
|
||||
duration("")
|
||||
|
||||
with pytest.raises(ArgumentTypeError):
|
||||
with pytest.raises(click.BadParameter):
|
||||
duration("100")
|
||||
|
||||
# Wrong order
|
||||
with pytest.raises(ArgumentTypeError):
|
||||
with pytest.raises(click.BadParameter):
|
||||
duration("1m1d")
|
||||
|
||||
with pytest.raises(ArgumentTypeError):
|
||||
with pytest.raises(click.BadParameter):
|
||||
duration("banana")
|
||||
|
||||
|
||||
def test_urlencode_url():
|
||||
assert urlencode_url("https://www.example.com") == "https://www.example.com"
|
||||
assert urlencode_url("https://www.example.com/url%20with%20spaces") == "https://www.example.com/url%20with%20spaces"
|
||||
|
45
tests/tui/test_rich_text.py
Normal file
@ -0,0 +1,45 @@
|
||||
from urwid import Divider, Filler, Pile
|
||||
from toot.tui.richtext import url_to_widget
|
||||
from urwidgets import Hyperlink, TextEmbed
|
||||
|
||||
from toot.tui.richtext.richtext import html_to_widgets
|
||||
|
||||
|
||||
def test_url_to_widget():
|
||||
url = "http://foo.bar"
|
||||
embed_widget = url_to_widget(url)
|
||||
assert isinstance(embed_widget, TextEmbed)
|
||||
|
||||
[(filler, length)] = embed_widget.embedded
|
||||
assert length == len(url)
|
||||
assert isinstance(filler, Filler)
|
||||
|
||||
link_widget = filler.base_widget
|
||||
assert isinstance(link_widget, Hyperlink)
|
||||
|
||||
assert link_widget.attrib == "link"
|
||||
assert link_widget.text == url
|
||||
assert link_widget.uri == url
|
||||
|
||||
|
||||
def test_html_to_widgets():
|
||||
html = """
|
||||
<p>foo</p>
|
||||
<p>foo <b>bar</b> <i>baz</i></p>
|
||||
""".strip()
|
||||
|
||||
[foo, divider, bar] = html_to_widgets(html)
|
||||
|
||||
assert isinstance(foo, Pile)
|
||||
assert isinstance(divider, Divider)
|
||||
assert isinstance(bar, Pile)
|
||||
|
||||
[(foo_embed, _)] = foo.contents
|
||||
assert foo_embed.embedded == []
|
||||
assert foo_embed.attrib == []
|
||||
assert foo_embed.text == "foo"
|
||||
|
||||
[(bar_embed, _)] = bar.contents
|
||||
assert bar_embed.embedded == []
|
||||
assert bar_embed.attrib == [(None, 4), ("b", 3), (None, 1), ("i", 3)]
|
||||
assert bar_embed.text == "foo bar baz"
|
@ -2,6 +2,9 @@
|
||||
Helpers for testing.
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
class MockResponse:
|
||||
def __init__(self, response_data={}, ok=True, is_redirect=False):
|
||||
@ -19,3 +22,23 @@ class MockResponse:
|
||||
|
||||
def retval(val):
|
||||
return lambda *args, **kwargs: val
|
||||
|
||||
|
||||
def run_with_retries(fn: Callable[..., Any]):
|
||||
"""
|
||||
Run the the given function repeatedly until it finishes without raising an
|
||||
AssertionError. Sleep a bit between attempts. If the function doesn't
|
||||
succeed in the given number of tries raises the AssertionError. Used for
|
||||
tests which should eventually succeed.
|
||||
"""
|
||||
|
||||
# Wait upto 6 seconds with incrementally longer sleeps
|
||||
delays = [0.1, 0.2, 0.3, 0.4, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5]
|
||||
|
||||
for delay in delays:
|
||||
try:
|
||||
return fn()
|
||||
except AssertionError:
|
||||
time.sleep(delay)
|
||||
|
||||
fn()
|
||||
|
@ -1,11 +1,45 @@
|
||||
from collections import namedtuple
|
||||
import os
|
||||
import sys
|
||||
|
||||
__version__ = '0.33.1'
|
||||
from os.path import join, expanduser
|
||||
from typing import NamedTuple
|
||||
|
||||
App = namedtuple('App', ['instance', 'base_url', 'client_id', 'client_secret'])
|
||||
User = namedtuple('User', ['instance', 'username', 'access_token'])
|
||||
__version__ = '0.41.1'
|
||||
|
||||
DEFAULT_INSTANCE = 'mastodon.social'
|
||||
|
||||
class App(NamedTuple):
|
||||
instance: str
|
||||
base_url: str
|
||||
client_id: str
|
||||
client_secret: str
|
||||
|
||||
|
||||
class User(NamedTuple):
|
||||
instance: str
|
||||
username: str
|
||||
access_token: str
|
||||
|
||||
|
||||
DEFAULT_INSTANCE = 'https://mastodon.social'
|
||||
|
||||
CLIENT_NAME = 'toot - a Mastodon CLI client'
|
||||
CLIENT_WEBSITE = 'https://github.com/ihabunek/toot'
|
||||
|
||||
TOOT_CONFIG_DIR_NAME = "toot"
|
||||
|
||||
|
||||
def get_config_dir():
|
||||
"""Returns the path to toot config directory"""
|
||||
|
||||
# On Windows, store the config in roaming appdata
|
||||
if sys.platform == "win32" and "APPDATA" in os.environ:
|
||||
return join(os.getenv("APPDATA"), TOOT_CONFIG_DIR_NAME)
|
||||
|
||||
# Respect XDG_CONFIG_HOME env variable if set
|
||||
# https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
|
||||
if "XDG_CONFIG_HOME" in os.environ:
|
||||
config_home = expanduser(os.environ["XDG_CONFIG_HOME"])
|
||||
return join(config_home, TOOT_CONFIG_DIR_NAME)
|
||||
|
||||
# Default to ~/.config/toot/
|
||||
return join(expanduser("~"), ".config", TOOT_CONFIG_DIR_NAME)
|
||||
|
3
toot/__main__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from toot.cli import cli
|
||||
|
||||
cli()
|
448
toot/api.py
@ -1,28 +1,56 @@
|
||||
import mimetypes
|
||||
import re
|
||||
import uuid
|
||||
|
||||
from os import path
|
||||
from requests import Response
|
||||
from typing import BinaryIO, List, Optional
|
||||
from urllib.parse import urlparse, urlencode, quote
|
||||
|
||||
from toot import http, CLIENT_NAME, CLIENT_WEBSITE
|
||||
from toot.exceptions import AuthenticationError, ApiError
|
||||
from toot.utils import str_bool
|
||||
from toot import App, User, http, CLIENT_NAME, CLIENT_WEBSITE
|
||||
from toot.exceptions import AuthenticationError, ApiError, ConsoleError
|
||||
from toot.utils import drop_empty_values, str_bool, str_bool_nullable
|
||||
|
||||
|
||||
SCOPES = 'read write follow'
|
||||
|
||||
|
||||
def _account_action(app, user, account, action):
|
||||
def find_account(app, user, account_name):
|
||||
if not account_name:
|
||||
raise ConsoleError("Empty account name given")
|
||||
|
||||
normalized_name = account_name.lstrip("@").lower()
|
||||
|
||||
# Strip @<instance_name> from accounts on the local instance. The `acct`
|
||||
# field in account object contains the qualified name for users of other
|
||||
# instances, but only the username for users of the local instance. This is
|
||||
# required in order to match the account name below.
|
||||
if "@" in normalized_name:
|
||||
[username, instance] = normalized_name.split("@", maxsplit=1)
|
||||
if instance == app.instance:
|
||||
normalized_name = username
|
||||
|
||||
response = search(app, user, account_name, type="accounts", resolve=True)
|
||||
for account in response.json()["accounts"]:
|
||||
if account["acct"].lower() == normalized_name:
|
||||
return account
|
||||
|
||||
raise ConsoleError("Account not found")
|
||||
|
||||
|
||||
def _account_action(app, user, account, action) -> Response:
|
||||
url = f"/api/v1/accounts/{account}/{action}"
|
||||
return http.post(app, user, url).json()
|
||||
return http.post(app, user, url)
|
||||
|
||||
|
||||
def _status_action(app, user, status_id, action, data=None):
|
||||
def _status_action(app, user, status_id, action, data=None) -> Response:
|
||||
url = f"/api/v1/statuses/{status_id}/{action}"
|
||||
return http.post(app, user, url, data=data).json()
|
||||
return http.post(app, user, url, data=data)
|
||||
|
||||
|
||||
def _tag_action(app, user, tag_name, action):
|
||||
def _tag_action(app, user, tag_name, action) -> Response:
|
||||
url = f"/api/v1/tags/{tag_name}/{action}"
|
||||
return http.post(app, user, url).json()
|
||||
return http.post(app, user, url)
|
||||
|
||||
|
||||
def _status_toggle_action(app, user, status_id, action, data=None):
|
||||
@ -48,6 +76,10 @@ def _status_toggle_action(app, user, status_id, action, data=None):
|
||||
def create_app(domain, scheme='https'):
|
||||
url = f"{scheme}://{domain}/api/v1/apps"
|
||||
|
||||
#def create_app(base_url):
|
||||
# url = f"{base_url}/api/v1/apps"
|
||||
|
||||
|
||||
json = {
|
||||
'client_name': CLIENT_NAME,
|
||||
'redirect_uris': 'urn:ietf:wg:oauth:2.0:oob',
|
||||
@ -86,6 +118,40 @@ def register_account(app, username, email, password, locale="en", agreement=True
|
||||
return http.anon_post(url, json=json, headers=headers).json()
|
||||
|
||||
|
||||
def update_account(
|
||||
app,
|
||||
user,
|
||||
display_name=None,
|
||||
note=None,
|
||||
avatar=None,
|
||||
header=None,
|
||||
bot=None,
|
||||
discoverable=None,
|
||||
locked=None,
|
||||
privacy=None,
|
||||
sensitive=None,
|
||||
language=None
|
||||
):
|
||||
"""
|
||||
Update account credentials
|
||||
https://docs.joinmastodon.org/methods/accounts/#update_credentials
|
||||
"""
|
||||
files = drop_empty_values({"avatar": avatar, "header": header})
|
||||
|
||||
data = drop_empty_values({
|
||||
"bot": str_bool_nullable(bot),
|
||||
"discoverable": str_bool_nullable(discoverable),
|
||||
"display_name": display_name,
|
||||
"locked": str_bool_nullable(locked),
|
||||
"note": note,
|
||||
"source[language]": language,
|
||||
"source[privacy]": privacy,
|
||||
"source[sensitive]": str_bool_nullable(sensitive),
|
||||
})
|
||||
|
||||
return http.patch(app, user, "/api/v1/accounts/update_credentials", files=files, data=data)
|
||||
|
||||
|
||||
def fetch_app_token(app):
|
||||
json = {
|
||||
"client_id": app.client_id,
|
||||
@ -98,7 +164,7 @@ def fetch_app_token(app):
|
||||
return http.anon_post(f"{app.base_url}/oauth/token", json=json).json()
|
||||
|
||||
|
||||
def login(app, username, password):
|
||||
def login(app: App, username: str, password: str):
|
||||
url = app.base_url + '/oauth/token'
|
||||
|
||||
data = {
|
||||
@ -110,16 +176,10 @@ def login(app, username, password):
|
||||
'scope': SCOPES,
|
||||
}
|
||||
|
||||
response = http.anon_post(url, data=data, allow_redirects=False)
|
||||
|
||||
# If auth fails, it redirects to the login page
|
||||
if response.is_redirect:
|
||||
raise AuthenticationError()
|
||||
|
||||
return response.json()
|
||||
return http.anon_post(url, data=data).json()
|
||||
|
||||
|
||||
def get_browser_login_url(app):
|
||||
def get_browser_login_url(app: App) -> str:
|
||||
"""Returns the URL for manual log in via browser"""
|
||||
return "{}/oauth/authorize/?{}".format(app.base_url, urlencode({
|
||||
"response_type": "code",
|
||||
@ -129,7 +189,7 @@ def get_browser_login_url(app):
|
||||
}))
|
||||
|
||||
|
||||
def request_access_token(app, authorization_code):
|
||||
def request_access_token(app: App, authorization_code: str):
|
||||
url = app.base_url + '/oauth/token'
|
||||
|
||||
data = {
|
||||
@ -147,7 +207,7 @@ def post_status(
|
||||
app,
|
||||
user,
|
||||
status,
|
||||
visibility='public',
|
||||
visibility=None,
|
||||
media_ids=None,
|
||||
sensitive=False,
|
||||
spoiler_text=None,
|
||||
@ -155,7 +215,11 @@ def post_status(
|
||||
language=None,
|
||||
scheduled_at=None,
|
||||
content_type=None,
|
||||
):
|
||||
poll_options=None,
|
||||
poll_expires_in=None,
|
||||
poll_multiple=None,
|
||||
poll_hide_totals=None,
|
||||
) -> Response:
|
||||
"""
|
||||
Publish a new status.
|
||||
https://docs.joinmastodon.org/methods/statuses/#create
|
||||
@ -165,7 +229,9 @@ def post_status(
|
||||
# if the request is retried.
|
||||
headers = {"Idempotency-Key": uuid.uuid4().hex}
|
||||
|
||||
json = {
|
||||
# Strip keys for which value is None
|
||||
# Sending null values doesn't bother Mastodon, but it breaks Pleroma
|
||||
data = drop_empty_values({
|
||||
'status': status,
|
||||
'media_ids': media_ids,
|
||||
'visibility': visibility,
|
||||
@ -174,14 +240,64 @@ def post_status(
|
||||
'language': language,
|
||||
'scheduled_at': scheduled_at,
|
||||
'content_type': content_type,
|
||||
'spoiler_text': spoiler_text
|
||||
}
|
||||
'spoiler_text': spoiler_text,
|
||||
})
|
||||
|
||||
if poll_options:
|
||||
data["poll"] = {
|
||||
"options": poll_options,
|
||||
"expires_in": poll_expires_in,
|
||||
"multiple": poll_multiple,
|
||||
"hide_totals": poll_hide_totals,
|
||||
}
|
||||
|
||||
return http.post(app, user, '/api/v1/statuses', json=data, headers=headers)
|
||||
|
||||
|
||||
def edit_status(
|
||||
app,
|
||||
user,
|
||||
id,
|
||||
status,
|
||||
visibility='public',
|
||||
media_ids=None,
|
||||
sensitive=False,
|
||||
spoiler_text=None,
|
||||
in_reply_to_id=None,
|
||||
language=None,
|
||||
content_type=None,
|
||||
poll_options=None,
|
||||
poll_expires_in=None,
|
||||
poll_multiple=None,
|
||||
poll_hide_totals=None,
|
||||
) -> Response:
|
||||
"""
|
||||
Edit an existing status
|
||||
https://docs.joinmastodon.org/methods/statuses/#edit
|
||||
"""
|
||||
|
||||
# Strip keys for which value is None
|
||||
# Sending null values doesn't bother Mastodon, but it breaks Pleroma
|
||||
json = {k: v for k, v in json.items() if v is not None}
|
||||
data = drop_empty_values({
|
||||
'status': status,
|
||||
'media_ids': media_ids,
|
||||
'visibility': visibility,
|
||||
'sensitive': sensitive,
|
||||
'in_reply_to_id': in_reply_to_id,
|
||||
'language': language,
|
||||
'content_type': content_type,
|
||||
'spoiler_text': spoiler_text,
|
||||
})
|
||||
|
||||
return http.post(app, user, '/api/v1/statuses', json=json, headers=headers).json()
|
||||
if poll_options:
|
||||
data["poll"] = {
|
||||
"options": poll_options,
|
||||
"expires_in": poll_expires_in,
|
||||
"multiple": poll_multiple,
|
||||
"hide_totals": poll_hide_totals,
|
||||
}
|
||||
|
||||
return http.put(app, user, f"/api/v1/statuses/{id}", json=data)
|
||||
|
||||
|
||||
def fetch_status(app, user, id):
|
||||
@ -189,7 +305,16 @@ def fetch_status(app, user, id):
|
||||
Fetch a single status
|
||||
https://docs.joinmastodon.org/methods/statuses/#get
|
||||
"""
|
||||
return http.get(app, user, f"/api/v1/statuses/{id}").json()
|
||||
return http.get(app, user, f"/api/v1/statuses/{id}")
|
||||
|
||||
|
||||
def fetch_status_source(app, user, id):
|
||||
"""
|
||||
Fetch the source (original text) for a single status.
|
||||
This only works on local toots.
|
||||
https://docs.joinmastodon.org/methods/statuses/#source
|
||||
"""
|
||||
return http.get(app, user, f"/api/v1/statuses/{id}/source")
|
||||
|
||||
|
||||
def scheduled_statuses(app, user):
|
||||
@ -246,14 +371,36 @@ def translate(app, user, status_id):
|
||||
return _status_action(app, user, status_id, 'translate')
|
||||
|
||||
|
||||
def context(app, user, status_id):
|
||||
def context(app, user, status_id) -> Response:
|
||||
url = f"/api/v1/statuses/{status_id}/context"
|
||||
return http.get(app, user, url).json()
|
||||
return http.get(app, user, url)
|
||||
|
||||
|
||||
def reblogged_by(app, user, status_id):
|
||||
def reblogged_by(app, user, status_id) -> Response:
|
||||
url = f"/api/v1/statuses/{status_id}/reblogged_by"
|
||||
return http.get(app, user, url).json()
|
||||
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):
|
||||
@ -265,6 +412,14 @@ def _get_next_path(headers):
|
||||
return "?".join([parsed.path, parsed.query])
|
||||
|
||||
|
||||
def _get_next_url(headers) -> Optional[str]:
|
||||
"""Given timeline response headers, returns the url to the next batch"""
|
||||
links = headers.get('Link', '')
|
||||
match = re.match('<([^>]+)>; rel="next"', links)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def _timeline_generator(app, user, path, params=None):
|
||||
while path:
|
||||
response = http.get(app, user, path, params)
|
||||
@ -272,9 +427,26 @@ def _timeline_generator(app, user, path, params=None):
|
||||
path = _get_next_path(response.headers)
|
||||
|
||||
|
||||
def _notification_timeline_generator(app, user, path, params=None):
|
||||
while path:
|
||||
response = http.get(app, user, path, params)
|
||||
notification = response.json()
|
||||
yield [n["status"] for n in notification if n["status"]]
|
||||
path = _get_next_path(response.headers)
|
||||
|
||||
|
||||
def _conversation_timeline_generator(app, user, path, params=None):
|
||||
while path:
|
||||
response = http.get(app, user, path, params)
|
||||
conversation = response.json()
|
||||
yield [c["last_status"] for c in conversation if c["last_status"]]
|
||||
path = _get_next_path(response.headers)
|
||||
|
||||
|
||||
def home_timeline_generator(app, user, limit=20):
|
||||
path = f"/api/v1/timelines/home?limit={limit}"
|
||||
return _timeline_generator(app, user, path)
|
||||
path = "/api/v1/timelines/home"
|
||||
params = {"limit": limit}
|
||||
return _timeline_generator(app, user, path, params)
|
||||
|
||||
|
||||
def public_timeline_generator(app, user, local=False, limit=20):
|
||||
@ -295,36 +467,88 @@ def bookmark_timeline_generator(app, user, limit=20):
|
||||
return _timeline_generator(app, user, path, params)
|
||||
|
||||
|
||||
def notification_timeline_generator(app, user, limit=20):
|
||||
# exclude all but mentions and statuses
|
||||
exclude_types = ["follow", "favourite", "reblog", "poll", "follow_request"]
|
||||
params = {"exclude_types[]": exclude_types, "limit": limit}
|
||||
return _notification_timeline_generator(app, user, "/api/v1/notifications", params)
|
||||
|
||||
|
||||
def conversation_timeline_generator(app, user, limit=20):
|
||||
path = "/api/v1/conversations"
|
||||
params = {"limit": limit}
|
||||
return _conversation_timeline_generator(app, user, path, params)
|
||||
|
||||
|
||||
def account_timeline_generator(app, user, account_name: str, replies=False, reblogs=False, limit=20):
|
||||
account = find_account(app, user, account_name)
|
||||
path = f"/api/v1/accounts/{account['id']}/statuses"
|
||||
params = {"limit": limit, "exclude_replies": not replies, "exclude_reblogs": not reblogs}
|
||||
return _timeline_generator(app, user, path, params)
|
||||
|
||||
|
||||
def timeline_list_generator(app, user, list_id, limit=20):
|
||||
path = f"/api/v1/timelines/list/{list_id}"
|
||||
return _timeline_generator(app, user, path, {'limit': limit})
|
||||
|
||||
|
||||
def _anon_timeline_generator(instance, path, params=None):
|
||||
while path:
|
||||
url = f"https://{instance}{path}"
|
||||
def _anon_timeline_generator(url, params=None):
|
||||
while url:
|
||||
response = http.anon_get(url, params)
|
||||
yield response.json()
|
||||
path = _get_next_path(response.headers)
|
||||
url = _get_next_url(response.headers)
|
||||
|
||||
|
||||
def anon_public_timeline_generator(instance, local=False, limit=20):
|
||||
path = '/api/v1/timelines/public'
|
||||
params = {'local': str_bool(local), 'limit': limit}
|
||||
return _anon_timeline_generator(instance, path, params)
|
||||
def anon_public_timeline_generator(base_url, local=False, limit=20):
|
||||
query = urlencode({"local": str_bool(local), "limit": limit})
|
||||
url = f"{base_url}/api/v1/timelines/public?{query}"
|
||||
return _anon_timeline_generator(url)
|
||||
|
||||
|
||||
def anon_tag_timeline_generator(instance, hashtag, local=False, limit=20):
|
||||
path = f"/api/v1/timelines/tag/{quote(hashtag)}"
|
||||
params = {'local': str_bool(local), 'limit': limit}
|
||||
return _anon_timeline_generator(instance, path, params)
|
||||
def anon_tag_timeline_generator(base_url, hashtag, local=False, limit=20):
|
||||
query = urlencode({"local": str_bool(local), "limit": limit})
|
||||
url = f"{base_url}/api/v1/timelines/tag/{quote(hashtag)}?{query}"
|
||||
return _anon_timeline_generator(url)
|
||||
|
||||
|
||||
def upload_media(app, user, file, description=None):
|
||||
return http.post(app, user, '/api/v1/media',
|
||||
data={'description': description},
|
||||
files={'file': file}
|
||||
).json()
|
||||
def get_media(app: App, user: User, id: str):
|
||||
return http.get(app, user, f"/api/v1/media/{id}").json()
|
||||
|
||||
|
||||
def upload_media(
|
||||
app: App,
|
||||
user: User,
|
||||
media: BinaryIO,
|
||||
description: Optional[str] = None,
|
||||
thumbnail: Optional[BinaryIO] = None,
|
||||
):
|
||||
data = drop_empty_values({"description": description})
|
||||
|
||||
# NB: Documentation says that "file" should provide a mime-type which we
|
||||
# don't do currently, but it works.
|
||||
files = drop_empty_values({
|
||||
"file": media,
|
||||
"thumbnail": _add_mime_type(thumbnail)
|
||||
})
|
||||
|
||||
return http.post(app, user, "/api/v2/media", data=data, files=files)
|
||||
|
||||
|
||||
def _add_mime_type(file):
|
||||
if file is None:
|
||||
return None
|
||||
|
||||
# TODO: mimetypes uses the file extension to guess the mime type which is
|
||||
# not always good enough (e.g. files without extension). python-magic could
|
||||
# be used instead but it requires adding it as a dependency.
|
||||
mime_type = mimetypes.guess_type(file.name)
|
||||
|
||||
if not mime_type:
|
||||
raise ConsoleError(f"Unable guess mime type of '{file.name}'. "
|
||||
"Ensure the file has the desired extension.")
|
||||
|
||||
filename = path.basename(file.name)
|
||||
return (filename, file, mime_type)
|
||||
|
||||
|
||||
def search(app, user, query, resolve=False, type=None):
|
||||
@ -332,11 +556,13 @@ def search(app, user, query, resolve=False, type=None):
|
||||
Perform a search.
|
||||
https://docs.joinmastodon.org/methods/search/#v2
|
||||
"""
|
||||
return http.get(app, user, "/api/v2/search", {
|
||||
params = drop_empty_values({
|
||||
"q": query,
|
||||
"resolve": resolve,
|
||||
"resolve": str_bool(resolve),
|
||||
"type": type
|
||||
}).json()
|
||||
})
|
||||
|
||||
return http.get(app, user, "/api/v2/search", params)
|
||||
|
||||
|
||||
def follow(app, user, account):
|
||||
@ -347,11 +573,11 @@ def unfollow(app, user, account):
|
||||
return _account_action(app, user, account, 'unfollow')
|
||||
|
||||
|
||||
def follow_tag(app, user, tag_name):
|
||||
def follow_tag(app, user, tag_name) -> Response:
|
||||
return _tag_action(app, user, tag_name, 'follow')
|
||||
|
||||
|
||||
def unfollow_tag(app, user, tag_name):
|
||||
def unfollow_tag(app, user, tag_name) -> Response:
|
||||
return _tag_action(app, user, tag_name, 'unfollow')
|
||||
|
||||
|
||||
@ -379,6 +605,58 @@ def followed_tags(app, user):
|
||||
return _get_response_list(app, user, path)
|
||||
|
||||
|
||||
def featured_tags(app, user):
|
||||
return http.get(app, user, "/api/v1/featured_tags")
|
||||
|
||||
|
||||
def feature_tag(app, user, tag: str) -> Response:
|
||||
return http.post(app, user, "/api/v1/featured_tags", data={"name": tag})
|
||||
|
||||
|
||||
def unfeature_tag(app, user, tag_id: str) -> Response:
|
||||
return http.delete(app, user, f"/api/v1/featured_tags/{tag_id}")
|
||||
|
||||
|
||||
def find_tag(app, user, tag) -> Optional[dict]:
|
||||
"""Find a hashtag by tag name or ID"""
|
||||
tag = tag.lstrip("#")
|
||||
results = search(app, user, tag, type="hashtags").json()
|
||||
|
||||
return next(
|
||||
(
|
||||
t for t in results["hashtags"]
|
||||
if t["name"].lower() == tag.lstrip("#").lower() or t["id"] == tag
|
||||
),
|
||||
None
|
||||
)
|
||||
|
||||
|
||||
def find_featured_tag(app, user, tag) -> Optional[dict]:
|
||||
"""Find a featured tag by tag name or ID"""
|
||||
return next(
|
||||
(
|
||||
t for t in featured_tags(app, user).json()
|
||||
if t["name"].lower() == tag.lstrip("#").lower() or t["id"] == tag
|
||||
),
|
||||
None
|
||||
)
|
||||
|
||||
|
||||
def whois(app, user, account):
|
||||
return http.get(app, user, f'/api/v1/accounts/{account}').json()
|
||||
|
||||
|
||||
def vote(app, user, poll_id, choices: List[int]):
|
||||
url = f"/api/v1/polls/{poll_id}/votes"
|
||||
json = {'choices': choices}
|
||||
return http.post(app, user, url, json=json).json()
|
||||
|
||||
|
||||
def get_relationship(app, user, account):
|
||||
params = {"id[]": account}
|
||||
return http.get(app, user, '/api/v1/accounts/relationships', params).json()[0]
|
||||
|
||||
|
||||
def mute(app, user, account):
|
||||
return _account_action(app, user, account, 'mute')
|
||||
|
||||
@ -387,6 +665,10 @@ def unmute(app, user, account):
|
||||
return _account_action(app, user, account, 'unmute')
|
||||
|
||||
|
||||
def muted(app, user):
|
||||
return _get_response_list(app, user, "/api/v1/mutes")
|
||||
|
||||
|
||||
def block(app, user, account):
|
||||
return _account_action(app, user, account, 'block')
|
||||
|
||||
@ -395,17 +677,16 @@ def unblock(app, user, account):
|
||||
return _account_action(app, user, account, 'unblock')
|
||||
|
||||
|
||||
def verify_credentials(app, user):
|
||||
return http.get(app, user, '/api/v1/accounts/verify_credentials').json()
|
||||
def blocked(app, user):
|
||||
return _get_response_list(app, user, "/api/v1/blocks")
|
||||
|
||||
|
||||
def single_status(app, user, status_id):
|
||||
url = f"/api/v1/statuses/{status_id}"
|
||||
return http.get(app, user, url).json()
|
||||
def verify_credentials(app, user) -> Response:
|
||||
return http.get(app, user, '/api/v1/accounts/verify_credentials')
|
||||
|
||||
|
||||
def get_notifications(app, user, exclude_types=[], limit=20):
|
||||
params = {"exclude_types[]": exclude_types, "limit": limit}
|
||||
def get_notifications(app, user, types=[], exclude_types=[], limit=20):
|
||||
params = {"types[]": types, "exclude_types[]": exclude_types, "limit": limit}
|
||||
return http.get(app, user, '/api/v1/notifications', params).json()
|
||||
|
||||
|
||||
@ -413,6 +694,43 @@ def clear_notifications(app, user):
|
||||
http.post(app, user, '/api/v1/notifications/clear')
|
||||
|
||||
|
||||
def get_instance(domain, scheme="https"):
|
||||
url = f"{scheme}://{domain}/api/v1/instance"
|
||||
return http.anon_get(url).json()
|
||||
def get_instance(base_url: str) -> Response:
|
||||
url = f"{base_url}/api/v1/instance"
|
||||
return http.anon_get(url)
|
||||
|
||||
|
||||
def get_preferences(app, user) -> Response:
|
||||
return http.get(app, user, '/api/v1/preferences')
|
||||
|
||||
|
||||
def get_lists(app, user):
|
||||
return http.get(app, user, "/api/v1/lists").json()
|
||||
|
||||
|
||||
def get_list_accounts(app, user, list_id):
|
||||
path = f"/api/v1/lists/{list_id}/accounts"
|
||||
return _get_response_list(app, user, path)
|
||||
|
||||
|
||||
def create_list(app, user, title, replies_policy="none"):
|
||||
url = "/api/v1/lists"
|
||||
json = {'title': title}
|
||||
if replies_policy:
|
||||
json['replies_policy'] = replies_policy
|
||||
return http.post(app, user, url, json=json)
|
||||
|
||||
|
||||
def delete_list(app, user, id):
|
||||
return http.delete(app, user, f"/api/v1/lists/{id}")
|
||||
|
||||
|
||||
def add_accounts_to_list(app, user, list_id, account_ids):
|
||||
url = f"/api/v1/lists/{list_id}/accounts"
|
||||
json = {'account_ids': account_ids}
|
||||
return http.post(app, user, url, json=json)
|
||||
|
||||
|
||||
def remove_accounts_from_list(app, user, list_id, account_ids):
|
||||
url = f"/api/v1/lists/{list_id}/accounts"
|
||||
json = {'account_ids': account_ids}
|
||||
return http.delete(app, user, url, json=json)
|
||||
|
120
toot/auth.py
@ -1,112 +1,74 @@
|
||||
import sys
|
||||
import webbrowser
|
||||
|
||||
from builtins import input
|
||||
from getpass import getpass
|
||||
|
||||
from toot import api, config, DEFAULT_INSTANCE, User, App
|
||||
from toot import api, config, User, App
|
||||
from toot.entities import from_dict, Instance
|
||||
from toot.exceptions import ApiError, ConsoleError
|
||||
from toot.output import print_out
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def register_app(domain, scheme='https'):
|
||||
print_out("Looking up instance info...")
|
||||
instance = api.get_instance(domain, scheme)
|
||||
|
||||
print_out("Found instance <blue>{}</blue> running Mastodon version <yellow>{}</yellow>".format(
|
||||
instance['title'], instance['version']))
|
||||
|
||||
def find_instance(base_url: str) -> Instance:
|
||||
try:
|
||||
print_out("Registering application...")
|
||||
response = api.create_app(domain, scheme)
|
||||
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:
|
||||
response = api.create_app(base_url)
|
||||
except ApiError:
|
||||
raise ConsoleError("Registration failed.")
|
||||
|
||||
base_url = scheme + '://' + domain
|
||||
|
||||
app = App(domain, base_url, response['client_id'], response['client_secret'])
|
||||
config.save_app(app)
|
||||
|
||||
print_out("Application tokens saved.")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def create_app_interactive(instance=None, scheme='https'):
|
||||
if not instance:
|
||||
print_out("Choose an instance [<green>{}</green>]: ".format(DEFAULT_INSTANCE), end="")
|
||||
instance = input()
|
||||
if not instance:
|
||||
instance = DEFAULT_INSTANCE
|
||||
|
||||
return config.load_app(instance) or register_app(instance, scheme)
|
||||
def get_or_create_app(base_url: str) -> App:
|
||||
instance = find_instance(base_url)
|
||||
domain = _get_instance_domain(instance)
|
||||
return config.load_app(domain) or register_app(domain, base_url)
|
||||
|
||||
|
||||
def create_user(app, access_token):
|
||||
def create_user(app: App, access_token: str) -> User:
|
||||
# Username is not yet known at this point, so fetch it from Mastodon
|
||||
user = User(app.instance, None, access_token)
|
||||
creds = api.verify_credentials(app, user)
|
||||
creds = api.verify_credentials(app, user).json()
|
||||
|
||||
user = User(app.instance, creds['username'], access_token)
|
||||
user = User(app.instance, creds["username"], access_token)
|
||||
config.save_user(user, activate=True)
|
||||
|
||||
print_out("Access token saved to config at: <green>{}</green>".format(
|
||||
config.get_config_file_path()))
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def login_interactive(app, email=None):
|
||||
print_out("Log in to <green>{}</green>".format(app.instance))
|
||||
|
||||
if email:
|
||||
print_out("Email: <green>{}</green>".format(email))
|
||||
|
||||
while not email:
|
||||
email = input('Email: ')
|
||||
|
||||
# Accept password piped from stdin, useful for testing purposes but not
|
||||
# documented so people won't get ideas. Otherwise prompt for password.
|
||||
if sys.stdin.isatty():
|
||||
password = getpass('Password: ')
|
||||
else:
|
||||
password = sys.stdin.read().strip()
|
||||
print_out("Password: <green>read from stdin</green>")
|
||||
|
||||
def login_username_password(app: App, email: str, password: str) -> User:
|
||||
try:
|
||||
print_out("Authenticating...")
|
||||
response = api.login(app, email, password)
|
||||
except ApiError:
|
||||
except Exception:
|
||||
raise ConsoleError("Login failed")
|
||||
|
||||
return create_user(app, response['access_token'])
|
||||
return create_user(app, response["access_token"])
|
||||
|
||||
|
||||
BROWSER_LOGIN_EXPLANATION = """
|
||||
This authentication method requires you to log into your Mastodon instance
|
||||
in your browser, where you will be asked to authorize <yellow>toot</yellow> to access
|
||||
your account. When you do, you will be given an <yellow>authorization code</yellow>
|
||||
which you need to paste here.
|
||||
"""
|
||||
def login_auth_code(app: App, authorization_code: str) -> User:
|
||||
try:
|
||||
response = api.request_access_token(app, authorization_code)
|
||||
except Exception:
|
||||
raise ConsoleError("Login failed")
|
||||
|
||||
return create_user(app, response["access_token"])
|
||||
|
||||
|
||||
def login_browser_interactive(app):
|
||||
url = api.get_browser_login_url(app)
|
||||
print_out(BROWSER_LOGIN_EXPLANATION)
|
||||
def _get_instance_domain(instance: Instance) -> str:
|
||||
"""Extracts the instance domain name.
|
||||
|
||||
print_out("This is the login URL:")
|
||||
print_out(url)
|
||||
print_out("")
|
||||
Pleroma and its forks return an actual URI here, rather than a domain name
|
||||
like Mastodon. This is contrary to the spec.¯ in that case, parse out the
|
||||
domain and return it.
|
||||
|
||||
yesno = input("Open link in default browser? [Y/n]")
|
||||
if not yesno or yesno.lower() == 'y':
|
||||
webbrowser.open(url)
|
||||
|
||||
authorization_code = ""
|
||||
while not authorization_code:
|
||||
authorization_code = input("Authorization code: ")
|
||||
|
||||
print_out("\nRequesting access token...")
|
||||
response = api.request_access_token(app, authorization_code)
|
||||
|
||||
return create_user(app, response['access_token'])
|
||||
TODO: when updating to v2 instance endpoint, this field has been renamed to
|
||||
`domain`
|
||||
"""
|
||||
if instance.uri.startswith("http"):
|
||||
return urlparse(instance.uri).netloc
|
||||
return instance.uri
|
||||
|
182
toot/cli/__init__.py
Normal file
@ -0,0 +1,182 @@
|
||||
import click
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import typing as t
|
||||
|
||||
from click.shell_completion import CompletionItem
|
||||
from click.types import StringParamType
|
||||
from functools import wraps
|
||||
|
||||
from toot import App, User, config, __version__
|
||||
from toot.output import print_warning
|
||||
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\""""
|
||||
|
||||
|
||||
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", {})
|
||||
|
||||
# TODO: remove in version 1.0
|
||||
tui_old = settings.get("tui", {}).copy()
|
||||
if "palette" in tui_old:
|
||||
del tui_old["palette"]
|
||||
if tui_old:
|
||||
# TODO: don't show the warning for [toot.palette]
|
||||
print_warning("Settings section [tui] has been deprecated in favour of [commands.tui].")
|
||||
tui_new = commands.get("tui", {})
|
||||
commands["tui"] = {**tui_old, **tui_new}
|
||||
|
||||
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
|
||||
|
||||
|
||||
class TootObj(t.NamedTuple):
|
||||
"""Data to add to Click context"""
|
||||
color: bool = True
|
||||
debug: bool = False
|
||||
as_user: t.Optional[str] = None
|
||||
# Pass a context for testing purposes
|
||||
test_ctx: t.Optional[Context] = None
|
||||
|
||||
|
||||
class AccountParamType(StringParamType):
|
||||
"""Custom type to add shell completion for account names"""
|
||||
name = "account"
|
||||
|
||||
def shell_complete(self, ctx, param, incomplete: str):
|
||||
users = config.load_config()["users"].keys()
|
||||
return [
|
||||
CompletionItem(u)
|
||||
for u in users
|
||||
if u.lower().startswith(incomplete.lower())
|
||||
]
|
||||
|
||||
|
||||
class InstanceParamType(StringParamType):
|
||||
"""Custom type to add shell completion for instance domains"""
|
||||
name = "instance"
|
||||
|
||||
def shell_complete(self, ctx, param, incomplete: str):
|
||||
apps = config.load_config()["apps"]
|
||||
|
||||
return [
|
||||
CompletionItem(i)
|
||||
for i in apps.keys()
|
||||
if i.lower().startswith(incomplete.lower())
|
||||
]
|
||||
|
||||
|
||||
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
|
||||
|
||||
if obj.as_user:
|
||||
user, app = config.get_user_app(obj.as_user)
|
||||
if not user or not app:
|
||||
raise click.ClickException(f"Account '{obj.as_user}' not found. Run `toot auth` to see available accounts.")
|
||||
else:
|
||||
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)
|
||||
|
||||
|
||||
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("--as", "as_user", type=AccountParamType(), help="The account to use, overrides the active account.")
|
||||
@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, as_user: str):
|
||||
"""Toot is a Mastodon CLI"""
|
||||
ctx.obj = TootObj(color, debug, as_user)
|
||||
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
@ -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")
|
143
toot/cli/auth.py
Normal file
@ -0,0 +1,143 @@
|
||||
import click
|
||||
import platform
|
||||
import sys
|
||||
import webbrowser
|
||||
|
||||
from toot import api, config, __version__
|
||||
from toot.auth import get_or_create_app, login_auth_code, login_username_password
|
||||
from toot.cli import AccountParamType, 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'""",
|
||||
)
|
||||
|
||||
|
||||
@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])
|
247
toot/cli/lists.py
Normal file
@ -0,0 +1,247 @@
|
||||
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`.\n" +
|
||||
"Run `toot lists -h` to see other list-related commands.")
|
||||
|
||||
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]
|
293
toot/cli/post.py
Normal file
@ -0,0 +1,293 @@
|
||||
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, config
|
||||
from toot.cli import AccountParamType, 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),
|
||||
)
|
||||
@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,
|
||||
)
|
||||
@click.option(
|
||||
"-u", "--using",
|
||||
type=AccountParamType(),
|
||||
help="The account to use, overrides the active account.",
|
||||
)
|
||||
@json_option
|
||||
@pass_context
|
||||
def post(
|
||||
ctx: Context,
|
||||
text: Optional[str],
|
||||
media: Tuple[str],
|
||||
descriptions: Tuple[str],
|
||||
thumbnails: Tuple[str],
|
||||
visibility: Optional[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,
|
||||
using: str
|
||||
):
|
||||
"""Post a new status"""
|
||||
if len(media) > 4:
|
||||
raise click.ClickException("Cannot attach more than 4 files.")
|
||||
|
||||
if using:
|
||||
user, app = config.get_user_app(using)
|
||||
if not user or not app:
|
||||
raise click.ClickException(f"Account '{using}' not found. Run `toot auth` to see available accounts.")
|
||||
else:
|
||||
user, app = ctx.user, ctx.app
|
||||
|
||||
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(
|
||||
app,
|
||||
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
@ -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 InstanceParamType, 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", type=InstanceParamType(), 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
@ -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
@ -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")
|
184
toot/cli/timelines.py
Normal file
@ -0,0 +1,184 @@
|
||||
import sys
|
||||
import click
|
||||
|
||||
from toot import api
|
||||
from toot.cli import InstanceParamType, 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",
|
||||
type=InstanceParamType(),
|
||||
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"]
|
58
toot/cli/tui.py
Normal file
@ -0,0 +1,58 @@
|
||||
import click
|
||||
|
||||
from typing import Optional
|
||||
from toot.cli import TUI_COLORS, VISIBILITY_CHOICES, 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."""
|
||||
)
|
||||
@click.option(
|
||||
"-v", "--default-visibility",
|
||||
type=click.Choice(VISIBILITY_CHOICES),
|
||||
help="Default visibility when posting new toots; overrides the server-side preference"
|
||||
)
|
||||
@click.option(
|
||||
"-S", "--always-show-sensitive",
|
||||
is_flag=True,
|
||||
help="Expand toots with content warnings automatically"
|
||||
)
|
||||
@pass_context
|
||||
def tui(
|
||||
ctx: Context,
|
||||
colors: Optional[int],
|
||||
media_viewer: Optional[str],
|
||||
always_show_sensitive: bool,
|
||||
relative_datetimes: bool,
|
||||
default_visibility: Optional[str]
|
||||
):
|
||||
"""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,
|
||||
default_visibility=default_visibility,
|
||||
always_show_sensitive=always_show_sensitive,
|
||||
)
|
||||
tui = TUI.create(ctx.app, ctx.user, options)
|
||||
tui.run()
|
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)}")
|
420
toot/commands.py
@ -1,420 +0,0 @@
|
||||
import sys
|
||||
import platform
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from toot import api, config, __version__
|
||||
from toot.auth import login_interactive, login_browser_interactive, create_app_interactive
|
||||
from toot.exceptions import ApiError, ConsoleError
|
||||
from toot.output import (print_out, print_instance, print_account, print_acct_list,
|
||||
print_search_results, print_timeline, print_notifications,
|
||||
print_tag_list)
|
||||
from toot.tui.utils import parse_datetime
|
||||
from toot.utils import editor_input, multiline_input, EOF_KEY
|
||||
|
||||
|
||||
def get_timeline_generator(app, user, args):
|
||||
# Make sure tag, list and public are not used simultaneously
|
||||
if len([arg for arg in [args.tag, args.list, args.public] if arg]) > 1:
|
||||
raise ConsoleError("Only one of --public, --tag, 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.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)
|
||||
|
||||
print_timeline(items)
|
||||
|
||||
if args.once or not sys.stdout.isatty():
|
||||
break
|
||||
|
||||
char = input("\nContinue? [Y/n] ")
|
||||
if char.lower() == "n":
|
||||
break
|
||||
|
||||
|
||||
def thread(app, user, args):
|
||||
toot = api.single_status(app, user, args.status_id)
|
||||
context = api.context(app, user, args.status_id)
|
||||
thread = []
|
||||
for item in context['ancestors']:
|
||||
thread.append(item)
|
||||
|
||||
thread.append(toot)
|
||||
|
||||
for item in context['descendants']:
|
||||
thread.append(item)
|
||||
|
||||
print_timeline(thread)
|
||||
|
||||
|
||||
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)
|
||||
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
|
||||
)
|
||||
|
||||
if "scheduled_at" in response:
|
||||
scheduled_at = parse_datetime(response["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>{response['url']}")
|
||||
|
||||
|
||||
def _get_status_text(text, editor):
|
||||
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:
|
||||
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 upload
|
||||
media = args.media or []
|
||||
descriptions = args.description or []
|
||||
uploaded_media = []
|
||||
|
||||
for idx, file in enumerate(media):
|
||||
description = descriptions[idx].strip() if idx < len(descriptions) else None
|
||||
result = _do_upload(app, user, file, description)
|
||||
uploaded_media.append(result)
|
||||
|
||||
return [m["id"] for m in uploaded_media]
|
||||
|
||||
|
||||
def delete(app, user, args):
|
||||
api.delete_status(app, user, args.status_id)
|
||||
print_out("<green>✓ Status deleted</green>")
|
||||
|
||||
|
||||
def favourite(app, user, args):
|
||||
api.favourite(app, user, args.status_id)
|
||||
print_out("<green>✓ Status favourited</green>")
|
||||
|
||||
|
||||
def unfavourite(app, user, args):
|
||||
api.unfavourite(app, user, args.status_id)
|
||||
print_out("<green>✓ Status unfavourited</green>")
|
||||
|
||||
|
||||
def reblog(app, user, args):
|
||||
api.reblog(app, user, args.status_id, visibility=args.visibility)
|
||||
print_out("<green>✓ Status reblogged</green>")
|
||||
|
||||
|
||||
def unreblog(app, user, args):
|
||||
api.unreblog(app, user, args.status_id)
|
||||
print_out("<green>✓ Status unreblogged</green>")
|
||||
|
||||
|
||||
def pin(app, user, args):
|
||||
api.pin(app, user, args.status_id)
|
||||
print_out("<green>✓ Status pinned</green>")
|
||||
|
||||
|
||||
def unpin(app, user, args):
|
||||
api.unpin(app, user, args.status_id)
|
||||
print_out("<green>✓ Status unpinned</green>")
|
||||
|
||||
|
||||
def bookmark(app, user, args):
|
||||
api.bookmark(app, user, args.status_id)
|
||||
print_out("<green>✓ Status bookmarked</green>")
|
||||
|
||||
|
||||
def unbookmark(app, user, args):
|
||||
api.unbookmark(app, user, args.status_id)
|
||||
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):
|
||||
for account in api.reblogged_by(app, user, args.status_id):
|
||||
print_out("{}\n @{}".format(account['display_name'], account['acct']))
|
||||
|
||||
|
||||
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 login_cli(app, user, args):
|
||||
app = create_app_interactive(instance=args.instance, scheme=args.scheme)
|
||||
login_interactive(app, args.email)
|
||||
|
||||
print_out()
|
||||
print_out("<green>✓ Successfully logged in.</green>")
|
||||
|
||||
|
||||
def login(app, user, args):
|
||||
app = create_app_interactive(instance=args.instance, scheme=args.scheme)
|
||||
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):
|
||||
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)
|
||||
|
||||
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)
|
||||
print_search_results(response)
|
||||
|
||||
|
||||
def _do_upload(app, user, file, description):
|
||||
print_out("Uploading media: <green>{}</green>".format(file.name))
|
||||
return api.upload_media(app, user, file, description=description)
|
||||
|
||||
|
||||
def _find_account(app, user, account_name):
|
||||
if not account_name:
|
||||
raise ConsoleError("Empty account name given")
|
||||
|
||||
normalized_name = account_name.lstrip("@").lower()
|
||||
|
||||
# Strip @<instance_name> from accounts on the local instance. The `acct`
|
||||
# field in account object contains the qualified name for users of other
|
||||
# instances, but only the username for users of the local instance. This is
|
||||
# required in order to match the account name below.
|
||||
if "@" in normalized_name:
|
||||
[username, instance] = normalized_name.split("@", maxsplit=1)
|
||||
if instance == app.instance:
|
||||
normalized_name = username
|
||||
|
||||
response = api.search(app, user, account_name, type="accounts", resolve=True)
|
||||
for account in response["accounts"]:
|
||||
if account["acct"].lower() == normalized_name:
|
||||
return account
|
||||
|
||||
raise ConsoleError("Account not found")
|
||||
|
||||
|
||||
def follow(app, user, args):
|
||||
account = _find_account(app, user, args.account)
|
||||
api.follow(app, user, account['id'])
|
||||
print_out("<green>✓ You are now following {}</green>".format(args.account))
|
||||
|
||||
|
||||
def unfollow(app, user, args):
|
||||
account = _find_account(app, user, args.account)
|
||||
api.unfollow(app, user, account['id'])
|
||||
print_out("<green>✓ You are no longer following {}</green>".format(args.account))
|
||||
|
||||
|
||||
def following(app, user, args):
|
||||
account = _find_account(app, user, args.account)
|
||||
response = api.following(app, user, account['id'])
|
||||
print_acct_list(response)
|
||||
|
||||
|
||||
def followers(app, user, args):
|
||||
account = _find_account(app, user, args.account)
|
||||
response = api.followers(app, user, account['id'])
|
||||
print_acct_list(response)
|
||||
|
||||
|
||||
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 mute(app, user, args):
|
||||
account = _find_account(app, user, args.account)
|
||||
api.mute(app, user, account['id'])
|
||||
print_out("<green>✓ You have muted {}</green>".format(args.account))
|
||||
|
||||
|
||||
def unmute(app, user, args):
|
||||
account = _find_account(app, user, args.account)
|
||||
api.unmute(app, user, account['id'])
|
||||
print_out("<green>✓ {} is no longer muted</green>".format(args.account))
|
||||
|
||||
|
||||
def block(app, user, args):
|
||||
account = _find_account(app, user, args.account)
|
||||
api.block(app, user, account['id'])
|
||||
print_out("<green>✓ You are now blocking {}</green>".format(args.account))
|
||||
|
||||
|
||||
def unblock(app, user, args):
|
||||
account = _find_account(app, user, args.account)
|
||||
api.unblock(app, user, account['id'])
|
||||
print_out("<green>✓ {} is no longer blocked</green>".format(args.account))
|
||||
|
||||
|
||||
def whoami(app, user, args):
|
||||
account = api.verify_credentials(app, user)
|
||||
print_account(account)
|
||||
|
||||
|
||||
def whois(app, user, args):
|
||||
account = _find_account(app, user, args.account)
|
||||
print_account(account)
|
||||
|
||||
|
||||
def instance(app, user, args):
|
||||
name = args.instance or (app and app.instance)
|
||||
if not name:
|
||||
raise ConsoleError("Please specify instance name.")
|
||||
|
||||
try:
|
||||
instance = api.get_instance(name, args.scheme)
|
||||
print_instance(instance)
|
||||
except ApiError:
|
||||
raise ConsoleError(
|
||||
"Instance not found at {}.\n"
|
||||
"The given domain probably does not host a Mastodon instance.".format(name)
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
print_notifications(notifications)
|
||||
|
||||
|
||||
def tui(app, user, args):
|
||||
from .tui.app import TUI
|
||||
TUI.create(app, user, args).run()
|
130
toot/config.py
@ -1,44 +1,22 @@
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
from functools import wraps
|
||||
from os.path import dirname, join, expanduser
|
||||
from contextlib import contextmanager
|
||||
from os.path import dirname, join
|
||||
from typing import Optional
|
||||
|
||||
from toot import User, App
|
||||
from toot import User, App, get_config_dir
|
||||
from toot.exceptions import ConsoleError
|
||||
from toot.output import print_out
|
||||
|
||||
|
||||
TOOT_CONFIG_DIR_NAME = "toot"
|
||||
TOOT_CONFIG_FILE_NAME = "config.json"
|
||||
|
||||
|
||||
def get_config_dir():
|
||||
"""Returns the path to toot config directory"""
|
||||
|
||||
# On Windows, store the config in roaming appdata
|
||||
if sys.platform == "win32" and "APPDATA" in os.environ:
|
||||
return join(os.getenv("APPDATA"), TOOT_CONFIG_DIR_NAME)
|
||||
|
||||
# Respect XDG_CONFIG_HOME env variable if set
|
||||
# https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
|
||||
if "XDG_CONFIG_HOME" in os.environ:
|
||||
config_home = expanduser(os.environ["XDG_CONFIG_HOME"])
|
||||
return join(config_home, TOOT_CONFIG_DIR_NAME)
|
||||
|
||||
# Default to ~/.config/toot/
|
||||
return join(expanduser("~"), ".config", TOOT_CONFIG_DIR_NAME)
|
||||
|
||||
|
||||
def get_config_file_path():
|
||||
"""Returns the path to toot config file."""
|
||||
return join(get_config_dir(), TOOT_CONFIG_FILE_NAME)
|
||||
|
||||
|
||||
CONFIG_FILE = get_config_file_path()
|
||||
|
||||
|
||||
def user_id(user):
|
||||
return "{}@{}".format(user.username, user.instance)
|
||||
|
||||
@ -51,8 +29,6 @@ def make_config(path):
|
||||
"active_user": None,
|
||||
}
|
||||
|
||||
print_out("Creating config file at <blue>{}</blue>".format(path))
|
||||
|
||||
# Ensure dir exists
|
||||
os.makedirs(dirname(path), exist_ok=True)
|
||||
|
||||
@ -63,15 +39,22 @@ def make_config(path):
|
||||
|
||||
|
||||
def load_config():
|
||||
if not os.path.exists(CONFIG_FILE):
|
||||
make_config(CONFIG_FILE)
|
||||
# Just to prevent accidentally running tests on production
|
||||
if os.environ.get("TOOT_TESTING"):
|
||||
raise Exception("Tests should not access the config file!")
|
||||
|
||||
with open(CONFIG_FILE) as f:
|
||||
path = get_config_file_path()
|
||||
|
||||
if not os.path.exists(path):
|
||||
make_config(path)
|
||||
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def save_config(config):
|
||||
with open(CONFIG_FILE, 'w') as f:
|
||||
path = get_config_file_path()
|
||||
with open(path, "w") as f:
|
||||
return json.dump(config, f, indent=True, sort_keys=True)
|
||||
|
||||
|
||||
@ -104,7 +87,7 @@ def get_user_app(user_id):
|
||||
return extract_user_app(load_config(), user_id)
|
||||
|
||||
|
||||
def load_app(instance):
|
||||
def load_app(instance: str) -> Optional[App]:
|
||||
config = load_config()
|
||||
if instance in config['apps']:
|
||||
return App(**config['apps'][instance])
|
||||
@ -120,63 +103,44 @@ def load_user(user_id, throw=False):
|
||||
raise ConsoleError("User '{}' not found".format(user_id))
|
||||
|
||||
|
||||
def modify_config(f):
|
||||
@wraps(f)
|
||||
def wrapper(*args, **kwargs):
|
||||
config = load_config()
|
||||
config = f(config, *args, **kwargs)
|
||||
save_config(config)
|
||||
return config
|
||||
|
||||
return wrapper
|
||||
def get_user_list():
|
||||
config = load_config()
|
||||
return config['users']
|
||||
|
||||
|
||||
@modify_config
|
||||
def save_app(config, app):
|
||||
assert isinstance(app, App)
|
||||
|
||||
config['apps'][app.instance] = app._asdict()
|
||||
|
||||
return config
|
||||
@contextmanager
|
||||
def edit_config():
|
||||
config = load_config()
|
||||
yield config
|
||||
save_config(config)
|
||||
|
||||
|
||||
def save_app(app: App):
|
||||
with edit_config() as config:
|
||||
config['apps'][app.instance] = app._asdict()
|
||||
|
||||
|
||||
@modify_config
|
||||
def delete_app(config, app):
|
||||
assert isinstance(app, App)
|
||||
|
||||
config['apps'].pop(app.instance, None)
|
||||
|
||||
return config
|
||||
with edit_config() as config:
|
||||
config['apps'].pop(app.instance, None)
|
||||
|
||||
|
||||
@modify_config
|
||||
def save_user(config, user, activate=True):
|
||||
assert isinstance(user, User)
|
||||
def save_user(user: User, activate=True):
|
||||
with edit_config() as config:
|
||||
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)
|
||||
|
||||
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
|
||||
|
698
toot/console.py
@ -1,698 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
from argparse import ArgumentParser, FileType, ArgumentTypeError
|
||||
from collections import namedtuple
|
||||
from itertools import chain
|
||||
from toot import config, commands, CLIENT_NAME, CLIENT_WEBSITE, __version__
|
||||
from toot.exceptions import ApiError, ConsoleError
|
||||
from toot.output import print_out, print_err
|
||||
|
||||
VISIBILITY_CHOICES = ['public', 'unlisted', 'private', 'direct']
|
||||
VISIBILITY_CHOICES_STR = ", ".join(f"'{v}'" for v in VISIBILITY_CHOICES)
|
||||
|
||||
|
||||
def get_default_visibility():
|
||||
return os.getenv("TOOT_POST_VISIBILITY", "public")
|
||||
|
||||
|
||||
def language(value):
|
||||
"""Validates the language parameter"""
|
||||
if len(value) != 2:
|
||||
raise ArgumentTypeError(
|
||||
"Invalid language. Expected a 2 letter abbreviation according to "
|
||||
"the ISO 639-1 standard."
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def visibility(value):
|
||||
"""Validates the visibility parameter"""
|
||||
if value not in VISIBILITY_CHOICES:
|
||||
raise ValueError("Invalid visibility value")
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def 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,
|
||||
}
|
||||
|
||||
|
||||
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'",
|
||||
})
|
||||
|
||||
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\"",
|
||||
})
|
||||
|
||||
# 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)",
|
||||
}),
|
||||
(["-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=[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,
|
||||
),
|
||||
]
|
||||
|
||||
TUI_COMMANDS = [
|
||||
Command(
|
||||
name="tui",
|
||||
description="Launches the toot terminal user interface",
|
||||
arguments=[
|
||||
(["--relative-datetimes"], {
|
||||
"action": "store_true",
|
||||
"default": False,
|
||||
"help": "Show relative datetimes in status list.",
|
||||
}),
|
||||
],
|
||||
require_auth=True,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
READ_COMMANDS = [
|
||||
Command(
|
||||
name="whoami",
|
||||
description="Display logged in user details",
|
||||
arguments=[],
|
||||
require_auth=True,
|
||||
),
|
||||
Command(
|
||||
name="whois",
|
||||
description="Display account details",
|
||||
arguments=[
|
||||
(["account"], {
|
||||
"help": "account name or numeric ID"
|
||||
}),
|
||||
],
|
||||
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,
|
||||
],
|
||||
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",
|
||||
}),
|
||||
],
|
||||
require_auth=True,
|
||||
),
|
||||
Command(
|
||||
name="thread",
|
||||
description="Show toot thread items",
|
||||
arguments=[
|
||||
(["status_id"], {
|
||||
"help": "Show thread for toot.",
|
||||
}),
|
||||
],
|
||||
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"
|
||||
}),
|
||||
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-2 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": """Schedule the toot to be posted after a given amount
|
||||
of time. Examples: "1 day", "2 hours 30 minutes",
|
||||
"5 minutes 30 seconds" or any combination of above.
|
||||
Shorthand: "1d", "2h30m", "5m30s". Must be at least 5
|
||||
minutes.""",
|
||||
}),
|
||||
(["-t", "--content-type"], {
|
||||
"type": str,
|
||||
"help": "MIME type for the status text (not supported on all instances)",
|
||||
}),
|
||||
],
|
||||
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],
|
||||
require_auth=True,
|
||||
),
|
||||
Command(
|
||||
name="favourite",
|
||||
description="Favourite a status",
|
||||
arguments=[status_id_arg],
|
||||
require_auth=True,
|
||||
),
|
||||
Command(
|
||||
name="unfavourite",
|
||||
description="Unfavourite a status",
|
||||
arguments=[status_id_arg],
|
||||
require_auth=True,
|
||||
),
|
||||
Command(
|
||||
name="reblog",
|
||||
description="Reblog a status",
|
||||
arguments=[status_id_arg, visibility_arg],
|
||||
require_auth=True,
|
||||
),
|
||||
Command(
|
||||
name="unreblog",
|
||||
description="Unreblog a status",
|
||||
arguments=[status_id_arg],
|
||||
require_auth=True,
|
||||
),
|
||||
Command(
|
||||
name="reblogged_by",
|
||||
description="Show accounts that reblogged the status",
|
||||
arguments=[status_id_arg],
|
||||
require_auth=False,
|
||||
),
|
||||
Command(
|
||||
name="pin",
|
||||
description="Pin a status",
|
||||
arguments=[status_id_arg],
|
||||
require_auth=True,
|
||||
),
|
||||
Command(
|
||||
name="unpin",
|
||||
description="Unpin a status",
|
||||
arguments=[status_id_arg],
|
||||
require_auth=True,
|
||||
),
|
||||
Command(
|
||||
name="bookmark",
|
||||
description="Bookmark a status",
|
||||
arguments=[status_id_arg],
|
||||
require_auth=True,
|
||||
),
|
||||
Command(
|
||||
name="unbookmark",
|
||||
description="Unbookmark a status",
|
||||
arguments=[status_id_arg],
|
||||
require_auth=True,
|
||||
),
|
||||
]
|
||||
|
||||
ACCOUNTS_COMMANDS = [
|
||||
Command(
|
||||
name="follow",
|
||||
description="Follow an account",
|
||||
arguments=[
|
||||
account_arg,
|
||||
],
|
||||
require_auth=True,
|
||||
),
|
||||
Command(
|
||||
name="unfollow",
|
||||
description="Unfollow an account",
|
||||
arguments=[
|
||||
account_arg,
|
||||
],
|
||||
require_auth=True,
|
||||
),
|
||||
Command(
|
||||
name="following",
|
||||
description="List accounts followed by the given account",
|
||||
arguments=[
|
||||
account_arg,
|
||||
],
|
||||
require_auth=True,
|
||||
),
|
||||
Command(
|
||||
name="followers",
|
||||
description="List accounts following the given account",
|
||||
arguments=[
|
||||
account_arg,
|
||||
],
|
||||
require_auth=True,
|
||||
),
|
||||
Command(
|
||||
name="mute",
|
||||
description="Mute an account",
|
||||
arguments=[
|
||||
account_arg,
|
||||
],
|
||||
require_auth=True,
|
||||
),
|
||||
Command(
|
||||
name="unmute",
|
||||
description="Unmute an account",
|
||||
arguments=[
|
||||
account_arg,
|
||||
],
|
||||
require_auth=True,
|
||||
),
|
||||
Command(
|
||||
name="block",
|
||||
description="Block an account",
|
||||
arguments=[
|
||||
account_arg,
|
||||
],
|
||||
require_auth=True,
|
||||
),
|
||||
Command(
|
||||
name="unblock",
|
||||
description="Unblock an account",
|
||||
arguments=[
|
||||
account_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,
|
||||
),
|
||||
]
|
||||
|
||||
COMMAND_GROUPS = [
|
||||
("Authentication", AUTH_COMMANDS),
|
||||
("TUI", TUI_COMMANDS),
|
||||
("Read", READ_COMMANDS),
|
||||
("Post", POST_COMMANDS),
|
||||
("Status", STATUS_COMMANDS),
|
||||
("Accounts", ACCOUNTS_COMMANDS),
|
||||
("Hashtags", TAG_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
|
||||
|
||||
for args, kwargs in combined_args:
|
||||
parser.add_argument(*args, **kwargs)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
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():
|
||||
# Enable debug logging if --debug is in args
|
||||
if "--debug" in sys.argv:
|
||||
filename = os.getenv("TOOT_LOG_FILE")
|
||||
logging.basicConfig(level=logging.DEBUG, filename=filename)
|
||||
|
||||
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
|
554
toot/entities.py
Normal file
@ -0,0 +1,554 @@
|
||||
"""
|
||||
Dataclasses which represent entities returned by the Mastodon API.
|
||||
|
||||
Data classes my have an optional static method named `__toot_prepare__` which is
|
||||
used when constructing the data class using `from_dict`. The method will be
|
||||
called with the dict and may modify it and return a modified dict. This is used
|
||||
to implement any pre-processing which may be required, e.g. to support
|
||||
different versions of the Mastodon API.
|
||||
"""
|
||||
|
||||
import dataclasses
|
||||
|
||||
from dataclasses import dataclass, is_dataclass
|
||||
from datetime import date, datetime
|
||||
from functools import lru_cache
|
||||
from typing import Any, Dict, List, Optional, Tuple, Type, TypeVar, Union
|
||||
from typing import get_type_hints
|
||||
|
||||
from toot.typing_compat import get_args, get_origin
|
||||
from toot.utils import get_text
|
||||
from toot.utils.datetime import parse_datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class AccountField:
|
||||
"""
|
||||
https://docs.joinmastodon.org/entities/Account/#Field
|
||||
"""
|
||||
name: str
|
||||
value: str
|
||||
verified_at: Optional[datetime]
|
||||
|
||||
|
||||
@dataclass
|
||||
class CustomEmoji:
|
||||
"""
|
||||
https://docs.joinmastodon.org/entities/CustomEmoji/
|
||||
"""
|
||||
shortcode: str
|
||||
url: str
|
||||
static_url: str
|
||||
visible_in_picker: bool
|
||||
category: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Account:
|
||||
"""
|
||||
https://docs.joinmastodon.org/entities/Account/
|
||||
"""
|
||||
id: str
|
||||
username: str
|
||||
acct: str
|
||||
url: str
|
||||
display_name: str
|
||||
note: str
|
||||
avatar: str
|
||||
avatar_static: str
|
||||
header: str
|
||||
header_static: str
|
||||
locked: bool
|
||||
fields: List[AccountField]
|
||||
emojis: List[CustomEmoji]
|
||||
bot: bool
|
||||
group: bool
|
||||
discoverable: Optional[bool]
|
||||
noindex: Optional[bool]
|
||||
moved: Optional["Account"]
|
||||
suspended: Optional[bool]
|
||||
limited: Optional[bool]
|
||||
created_at: datetime
|
||||
last_status_at: Optional[date]
|
||||
statuses_count: int
|
||||
followers_count: int
|
||||
following_count: int
|
||||
source: Optional[dict]
|
||||
|
||||
@staticmethod
|
||||
def __toot_prepare__(obj: Dict) -> Dict:
|
||||
# Pleroma has not yet converted last_status_at from datetime to date
|
||||
# so trim it here so it doesn't break when converting to date.
|
||||
# See: https://git.pleroma.social/pleroma/pleroma/-/issues/1470
|
||||
last_status_at = obj.get("last_status_at")
|
||||
if last_status_at:
|
||||
obj.update(last_status_at=obj["last_status_at"][:10])
|
||||
return obj
|
||||
|
||||
@property
|
||||
def note_plaintext(self) -> str:
|
||||
return get_text(self.note)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Application:
|
||||
"""
|
||||
https://docs.joinmastodon.org/entities/Status/#application
|
||||
"""
|
||||
name: str
|
||||
website: Optional[str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class MediaAttachment:
|
||||
"""
|
||||
https://docs.joinmastodon.org/entities/MediaAttachment/
|
||||
"""
|
||||
id: str
|
||||
type: str
|
||||
url: str
|
||||
preview_url: str
|
||||
remote_url: Optional[str]
|
||||
meta: dict
|
||||
description: str
|
||||
blurhash: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class StatusMention:
|
||||
"""
|
||||
https://docs.joinmastodon.org/entities/Status/#Mention
|
||||
"""
|
||||
id: str
|
||||
username: str
|
||||
url: str
|
||||
acct: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class StatusTag:
|
||||
"""
|
||||
https://docs.joinmastodon.org/entities/Status/#Tag
|
||||
"""
|
||||
name: str
|
||||
url: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class PollOption:
|
||||
"""
|
||||
https://docs.joinmastodon.org/entities/Poll/#Option
|
||||
"""
|
||||
title: str
|
||||
votes_count: Optional[int]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Poll:
|
||||
"""
|
||||
https://docs.joinmastodon.org/entities/Poll/
|
||||
"""
|
||||
id: str
|
||||
expires_at: Optional[datetime]
|
||||
expired: bool
|
||||
multiple: bool
|
||||
votes_count: int
|
||||
voters_count: Optional[int]
|
||||
options: List[PollOption]
|
||||
emojis: List[CustomEmoji]
|
||||
voted: Optional[bool]
|
||||
own_votes: Optional[List[int]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class PreviewCard:
|
||||
"""
|
||||
https://docs.joinmastodon.org/entities/PreviewCard/
|
||||
"""
|
||||
url: str
|
||||
title: str
|
||||
description: str
|
||||
type: str
|
||||
author_name: str
|
||||
author_url: str
|
||||
provider_name: str
|
||||
provider_url: str
|
||||
html: str
|
||||
width: int
|
||||
height: int
|
||||
image: Optional[str]
|
||||
embed_url: str
|
||||
blurhash: Optional[str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class FilterKeyword:
|
||||
"""
|
||||
https://docs.joinmastodon.org/entities/FilterKeyword/
|
||||
"""
|
||||
id: str
|
||||
keyword: str
|
||||
whole_word: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class FilterStatus:
|
||||
"""
|
||||
https://docs.joinmastodon.org/entities/FilterStatus/
|
||||
"""
|
||||
id: str
|
||||
status_id: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Filter:
|
||||
"""
|
||||
https://docs.joinmastodon.org/entities/Filter/
|
||||
"""
|
||||
id: str
|
||||
title: str
|
||||
context: List[str]
|
||||
expires_at: Optional[datetime]
|
||||
filter_action: str
|
||||
keywords: List[FilterKeyword]
|
||||
statuses: List[FilterStatus]
|
||||
|
||||
|
||||
@dataclass
|
||||
class FilterResult:
|
||||
"""
|
||||
https://docs.joinmastodon.org/entities/FilterResult/
|
||||
"""
|
||||
filter: Filter
|
||||
keyword_matches: Optional[List[str]]
|
||||
status_matches: Optional[str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Status:
|
||||
"""
|
||||
https://docs.joinmastodon.org/entities/Status/
|
||||
"""
|
||||
id: str
|
||||
uri: str
|
||||
created_at: datetime
|
||||
account: Account
|
||||
content: str
|
||||
visibility: str
|
||||
sensitive: bool
|
||||
spoiler_text: str
|
||||
media_attachments: List[MediaAttachment]
|
||||
application: Optional[Application]
|
||||
mentions: List[StatusMention]
|
||||
tags: List[StatusTag]
|
||||
emojis: List[CustomEmoji]
|
||||
reblogs_count: int
|
||||
favourites_count: int
|
||||
replies_count: int
|
||||
url: Optional[str]
|
||||
in_reply_to_id: Optional[str]
|
||||
in_reply_to_account_id: Optional[str]
|
||||
reblog: Optional["Status"]
|
||||
poll: Optional[Poll]
|
||||
card: Optional[PreviewCard]
|
||||
language: Optional[str]
|
||||
text: Optional[str]
|
||||
edited_at: Optional[datetime]
|
||||
favourited: Optional[bool]
|
||||
reblogged: Optional[bool]
|
||||
muted: Optional[bool]
|
||||
bookmarked: Optional[bool]
|
||||
pinned: Optional[bool]
|
||||
filtered: Optional[List[FilterResult]]
|
||||
|
||||
@property
|
||||
def original(self) -> "Status":
|
||||
return self.reblog or self
|
||||
|
||||
@staticmethod
|
||||
def __toot_prepare__(obj: Dict) -> Dict:
|
||||
# Pleroma has a bug where created_at is set to an empty string.
|
||||
# To avoid marking created_at as optional, which would require work
|
||||
# because we count on it always existing, set it to current datetime.
|
||||
# Possible underlying issue:
|
||||
# https://git.pleroma.social/pleroma/pleroma/-/issues/2851
|
||||
if not obj["created_at"]:
|
||||
obj["created_at"] = datetime.now().astimezone().isoformat()
|
||||
return obj
|
||||
|
||||
|
||||
@dataclass
|
||||
class Report:
|
||||
"""
|
||||
https://docs.joinmastodon.org/entities/Report/
|
||||
"""
|
||||
id: str
|
||||
action_taken: bool
|
||||
action_taken_at: Optional[datetime]
|
||||
category: str
|
||||
comment: str
|
||||
forwarded: bool
|
||||
created_at: datetime
|
||||
status_ids: Optional[List[str]]
|
||||
rule_ids: Optional[List[str]]
|
||||
target_account: Account
|
||||
|
||||
|
||||
@dataclass
|
||||
class Notification:
|
||||
"""
|
||||
https://docs.joinmastodon.org/entities/Notification/
|
||||
"""
|
||||
id: str
|
||||
type: str
|
||||
created_at: datetime
|
||||
account: Account
|
||||
status: Optional[Status]
|
||||
report: Optional[Report]
|
||||
|
||||
|
||||
@dataclass
|
||||
class InstanceUrls:
|
||||
streaming_api: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class InstanceStats:
|
||||
user_count: int
|
||||
status_count: int
|
||||
domain_count: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class InstanceConfigurationStatuses:
|
||||
max_characters: int
|
||||
max_media_attachments: int
|
||||
characters_reserved_per_url: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class InstanceConfigurationMediaAttachments:
|
||||
supported_mime_types: List[str]
|
||||
image_size_limit: int
|
||||
image_matrix_limit: int
|
||||
video_size_limit: int
|
||||
video_frame_rate_limit: int
|
||||
video_matrix_limit: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class InstanceConfigurationPolls:
|
||||
max_options: int
|
||||
max_characters_per_option: int
|
||||
min_expiration: int
|
||||
max_expiration: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class InstanceConfiguration:
|
||||
"""
|
||||
https://docs.joinmastodon.org/entities/V1_Instance/#configuration
|
||||
"""
|
||||
statuses: InstanceConfigurationStatuses
|
||||
media_attachments: InstanceConfigurationMediaAttachments
|
||||
polls: InstanceConfigurationPolls
|
||||
|
||||
|
||||
@dataclass
|
||||
class Rule:
|
||||
"""
|
||||
https://docs.joinmastodon.org/entities/Rule/
|
||||
"""
|
||||
id: str
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Instance:
|
||||
"""
|
||||
https://docs.joinmastodon.org/entities/V1_Instance/
|
||||
"""
|
||||
uri: str
|
||||
title: str
|
||||
short_description: str
|
||||
description: str
|
||||
email: str
|
||||
version: str
|
||||
urls: InstanceUrls
|
||||
stats: InstanceStats
|
||||
thumbnail: Optional[str]
|
||||
languages: List[str]
|
||||
registrations: bool
|
||||
approval_required: bool
|
||||
invites_enabled: bool
|
||||
configuration: InstanceConfiguration
|
||||
contact_account: Optional[Account]
|
||||
rules: List[Rule]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Relationship:
|
||||
"""
|
||||
Represents the relationship between accounts, such as following / blocking /
|
||||
muting / etc.
|
||||
https://docs.joinmastodon.org/entities/Relationship/
|
||||
"""
|
||||
id: str
|
||||
following: bool
|
||||
showing_reblogs: bool
|
||||
notifying: bool
|
||||
languages: List[str]
|
||||
followed_by: bool
|
||||
blocking: bool
|
||||
blocked_by: bool
|
||||
muting: bool
|
||||
muting_notifications: bool
|
||||
requested: bool
|
||||
domain_blocking: bool
|
||||
endorsed: bool
|
||||
note: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class TagHistory:
|
||||
"""
|
||||
Usage statistics for given days (typically the past week).
|
||||
https://docs.joinmastodon.org/entities/Tag/#history
|
||||
"""
|
||||
day: str
|
||||
uses: str
|
||||
accounts: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Tag:
|
||||
"""
|
||||
Represents a hashtag used within the content of a status.
|
||||
https://docs.joinmastodon.org/entities/Tag/
|
||||
"""
|
||||
name: str
|
||||
url: str
|
||||
history: List[TagHistory]
|
||||
following: Optional[bool]
|
||||
|
||||
|
||||
@dataclass
|
||||
class FeaturedTag:
|
||||
"""
|
||||
Represents a hashtag that is featured on a profile.
|
||||
https://docs.joinmastodon.org/entities/FeaturedTag/
|
||||
"""
|
||||
id: str
|
||||
name: str
|
||||
url: str
|
||||
statuses_count: int
|
||||
last_status_at: datetime
|
||||
|
||||
|
||||
# Generic data class instance
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class ConversionError(Exception):
|
||||
"""Raised when conversion fails from JSON value to data class field."""
|
||||
def __init__(
|
||||
self,
|
||||
data_class: Type,
|
||||
field_name: str,
|
||||
field_type: Type,
|
||||
field_value: Optional[str]
|
||||
):
|
||||
super().__init__(
|
||||
f"Failed converting field `{data_class.__name__}.{field_name}` "
|
||||
+ f"of type `{field_type.__name__}` from value {field_value!r}"
|
||||
)
|
||||
|
||||
|
||||
def from_dict(cls: Type[T], data: Dict) -> T:
|
||||
"""Convert a nested dict into an instance of `cls`."""
|
||||
# Apply __toot_prepare__ if it exists
|
||||
prepare = getattr(cls, '__toot_prepare__', None)
|
||||
if prepare:
|
||||
data = prepare(data)
|
||||
|
||||
def _fields():
|
||||
for name, type, default in get_fields(cls):
|
||||
value = data.get(name, default)
|
||||
converted = _convert_with_error_handling(cls, name, type, value)
|
||||
yield name, converted
|
||||
|
||||
return cls(**dict(_fields()))
|
||||
|
||||
|
||||
@lru_cache(maxsize=100)
|
||||
def get_fields(cls: Type) -> List[Tuple[str, Type, Any]]:
|
||||
hints = get_type_hints(cls)
|
||||
return [
|
||||
(
|
||||
field.name,
|
||||
_prune_optional(hints[field.name]),
|
||||
_get_default_value(field)
|
||||
)
|
||||
for field in dataclasses.fields(cls)
|
||||
]
|
||||
|
||||
|
||||
def from_dict_list(cls: Type[T], data: List[Dict]) -> List[T]:
|
||||
return [from_dict(cls, x) for x in data]
|
||||
|
||||
|
||||
def _get_default_value(field):
|
||||
if field.default is not dataclasses.MISSING:
|
||||
return field.default
|
||||
|
||||
if field.default_factory is not dataclasses.MISSING:
|
||||
return field.default_factory()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _convert_with_error_handling(
|
||||
data_class: Type,
|
||||
field_name: str,
|
||||
field_type: Type,
|
||||
field_value: Optional[str]
|
||||
):
|
||||
try:
|
||||
return _convert(field_type, field_value)
|
||||
except ConversionError:
|
||||
raise
|
||||
except Exception:
|
||||
raise ConversionError(data_class, field_name, field_type, field_value)
|
||||
|
||||
|
||||
def _convert(field_type, value):
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
if field_type in [str, int, bool, dict]:
|
||||
return value
|
||||
|
||||
if field_type == datetime:
|
||||
return parse_datetime(value)
|
||||
|
||||
if field_type == date:
|
||||
return date.fromisoformat(value)
|
||||
|
||||
if get_origin(field_type) == list:
|
||||
(inner_type,) = get_args(field_type)
|
||||
return [_convert(inner_type, x) for x in value]
|
||||
|
||||
if is_dataclass(field_type):
|
||||
return from_dict(field_type, value)
|
||||
|
||||
raise ValueError(f"Not implemented for type '{field_type}'")
|
||||
|
||||
|
||||
def _prune_optional(field_type: Type) -> Type:
|
||||
"""For `Optional[<type>]` returns the encapsulated `<type>`."""
|
||||
if get_origin(field_type) == Union:
|
||||
args = get_args(field_type)
|
||||
if len(args) == 2 and args[1] == type(None): # noqa
|
||||
return args[0]
|
||||
|
||||
return field_type
|
@ -1,4 +1,7 @@
|
||||
class ApiError(Exception):
|
||||
from click import ClickException
|
||||
|
||||
|
||||
class ApiError(ClickException):
|
||||
"""Raised when an API request fails for whatever reason."""
|
||||
|
||||
|
||||
@ -10,5 +13,5 @@ class AuthenticationError(ApiError):
|
||||
"""Raised when login fails."""
|
||||
|
||||
|
||||
class ConsoleError(Exception):
|
||||
class ConsoleError(ClickException):
|
||||
"""Raised when an error occurs which needs to be show to the user."""
|
||||
|
37
toot/http.py
@ -3,7 +3,7 @@ from requests.exceptions import RequestException
|
||||
|
||||
from toot import __version__
|
||||
from toot.exceptions import NotFoundError, ApiError
|
||||
from toot.logging import log_request, log_response
|
||||
from toot.logging import log_request, log_request_exception, log_response
|
||||
|
||||
|
||||
def send_request(request, allow_redirects=True):
|
||||
@ -19,6 +19,7 @@ def send_request(request, allow_redirects=True):
|
||||
settings = session.merge_environment_settings(prepared.url, {}, None, None, None)
|
||||
response = session.send(prepared, allow_redirects=allow_redirects, **settings)
|
||||
except RequestException as ex:
|
||||
log_request_exception(request, ex)
|
||||
raise ApiError(f"Request failed: {str(ex)}")
|
||||
|
||||
log_response(response)
|
||||
@ -37,7 +38,7 @@ def _get_error_message(response):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return "Unknown error"
|
||||
return f"Unknown error: {response.status_code} {response.reason}"
|
||||
|
||||
|
||||
def process_response(response):
|
||||
@ -80,13 +81,41 @@ def post(app, user, path, headers=None, files=None, data=None, json=None, allow_
|
||||
return anon_post(url, headers=headers, files=files, data=data, json=json, allow_redirects=allow_redirects)
|
||||
|
||||
|
||||
def delete(app, user, path, data=None, headers=None):
|
||||
def anon_put(url, headers=None, files=None, data=None, json=None, allow_redirects=True):
|
||||
request = Request(method="PUT", url=url, headers=headers, files=files, data=data, json=json)
|
||||
response = send_request(request, allow_redirects)
|
||||
|
||||
return process_response(response)
|
||||
|
||||
|
||||
def put(app, user, path, headers=None, files=None, data=None, json=None, allow_redirects=True):
|
||||
url = app.base_url + path
|
||||
|
||||
headers = headers or {}
|
||||
headers["Authorization"] = f"Bearer {user.access_token}"
|
||||
|
||||
request = Request('DELETE', url, headers=headers, json=data)
|
||||
return anon_put(url, headers=headers, files=files, data=data, json=json, allow_redirects=allow_redirects)
|
||||
|
||||
|
||||
def patch(app, user, path, headers=None, files=None, data=None, json=None):
|
||||
url = app.base_url + path
|
||||
|
||||
headers = headers or {}
|
||||
headers["Authorization"] = f"Bearer {user.access_token}"
|
||||
|
||||
request = Request('PATCH', url, headers=headers, files=files, data=data, json=json)
|
||||
response = send_request(request)
|
||||
|
||||
return process_response(response)
|
||||
|
||||
|
||||
def delete(app, user, path, data=None, json=None, headers=None):
|
||||
url = app.base_url + path
|
||||
|
||||
headers = headers or {}
|
||||
headers["Authorization"] = f"Bearer {user.access_token}"
|
||||
|
||||
request = Request('DELETE', url, headers=headers, data=data, json=json)
|
||||
response = send_request(request)
|
||||
|
||||
return process_response(response)
|
||||
|
@ -2,22 +2,12 @@ import json
|
||||
import sys
|
||||
|
||||
from logging import getLogger
|
||||
from requests import Request, RequestException, Response
|
||||
from urllib.parse import urlencode
|
||||
|
||||
logger = getLogger('toot')
|
||||
logger = getLogger("toot")
|
||||
|
||||
VERBOSE = "--verbose" in sys.argv
|
||||
COLOR = "--no-color" not in sys.argv
|
||||
|
||||
if COLOR:
|
||||
ANSI_RED = "\033[31m"
|
||||
ANSI_GREEN = "\033[32m"
|
||||
ANSI_YELLOW = "\033[33m"
|
||||
ANSI_END_COLOR = "\033[0m"
|
||||
else:
|
||||
ANSI_RED = ""
|
||||
ANSI_GREEN = ""
|
||||
ANSI_YELLOW = ""
|
||||
ANSI_END_COLOR = ""
|
||||
|
||||
|
||||
def censor_secrets(headers):
|
||||
@ -36,40 +26,42 @@ def truncate(line):
|
||||
return line
|
||||
|
||||
|
||||
def log_request(request):
|
||||
def log_request(request: Request):
|
||||
logger.debug(f" --> {request.method} {_url(request)}")
|
||||
|
||||
logger.debug(f">>> {ANSI_GREEN}{request.method} {request.url}{ANSI_END_COLOR}")
|
||||
|
||||
if request.headers:
|
||||
if VERBOSE and request.headers:
|
||||
headers = censor_secrets(request.headers)
|
||||
logger.debug(f">>> HEADERS: {ANSI_GREEN}{headers}{ANSI_END_COLOR}")
|
||||
logger.debug(f" --> HEADERS: {headers}")
|
||||
|
||||
if request.data:
|
||||
if VERBOSE and request.data:
|
||||
data = truncate(request.data)
|
||||
logger.debug(f">>> DATA: {ANSI_GREEN}{data}{ANSI_END_COLOR}")
|
||||
logger.debug(f" --> DATA: {data}")
|
||||
|
||||
if request.json:
|
||||
if VERBOSE and request.json:
|
||||
data = truncate(json.dumps(request.json))
|
||||
logger.debug(f">>> JSON: {ANSI_GREEN}{data}{ANSI_END_COLOR}")
|
||||
logger.debug(f" --> JSON: {data}")
|
||||
|
||||
if request.files:
|
||||
logger.debug(f">>> FILES: {ANSI_GREEN}{request.files}{ANSI_END_COLOR}")
|
||||
if VERBOSE and request.files:
|
||||
logger.debug(f" --> FILES: {request.files}")
|
||||
|
||||
|
||||
def log_response(response: Response):
|
||||
method = response.request.method
|
||||
url = response.request.url
|
||||
elapsed = response.elapsed.microseconds // 1000
|
||||
logger.debug(f" <-- {method} {url} HTTP {response.status_code} {elapsed}ms")
|
||||
|
||||
if VERBOSE and response.content:
|
||||
content = truncate(response.content.decode())
|
||||
logger.debug(f" <-- {content}")
|
||||
|
||||
|
||||
def log_request_exception(request: Request, ex: RequestException):
|
||||
logger.debug(f" <-- {request.method} {_url(request)} Exception: {ex}")
|
||||
|
||||
|
||||
def _url(request):
|
||||
url = request.url
|
||||
if request.params:
|
||||
logger.debug(f">>> PARAMS: {ANSI_GREEN}{request.params}{ANSI_END_COLOR}")
|
||||
|
||||
|
||||
def log_response(response):
|
||||
|
||||
content = truncate(response.content.decode())
|
||||
|
||||
if response.ok:
|
||||
logger.debug(f"<<< {ANSI_GREEN}{response}{ANSI_END_COLOR}")
|
||||
logger.debug(f"<<< {ANSI_YELLOW}{content}{ANSI_END_COLOR}")
|
||||
else:
|
||||
logger.debug(f"<<< {ANSI_RED}{response}{ANSI_END_COLOR}")
|
||||
logger.debug(f"<<< {ANSI_RED}{content}{ANSI_END_COLOR}")
|
||||
|
||||
|
||||
def log_debug(*msgs):
|
||||
logger.debug(" ".join(str(m) for m in msgs))
|
||||
url += f"?{urlencode(request.params)}"
|
||||
return url
|
||||
|
496
toot/output.py
@ -1,342 +1,340 @@
|
||||
import os
|
||||
import click
|
||||
import re
|
||||
import sys
|
||||
import textwrap
|
||||
import shutil
|
||||
|
||||
from toot.tui.utils import parse_datetime
|
||||
from toot.entities import Account, Instance, Notification, Poll, Status
|
||||
from toot.utils import get_text, html_to_paragraphs
|
||||
from toot.wcstring import wc_wrap
|
||||
from typing import Any, Generator, Iterable, List
|
||||
from wcwidth import wcswidth
|
||||
|
||||
from toot.utils import get_text, parse_html
|
||||
from toot.wcstring import wc_wrap
|
||||
|
||||
DEFAULT_WIDTH = 80
|
||||
|
||||
|
||||
STYLES = {
|
||||
'reset': '\033[0m',
|
||||
'bold': '\033[1m',
|
||||
'dim': '\033[2m',
|
||||
'italic': '\033[3m',
|
||||
'underline': '\033[4m',
|
||||
'red': '\033[91m',
|
||||
'green': '\033[92m',
|
||||
'yellow': '\033[93m',
|
||||
'blue': '\033[94m',
|
||||
'magenta': '\033[95m',
|
||||
'cyan': '\033[96m',
|
||||
}
|
||||
|
||||
STYLE_TAG_PATTERN = re.compile(r"""
|
||||
(?<!\\) # not preceeded by a backslash - allows escaping
|
||||
< # literal
|
||||
(/)? # optional closing - first group
|
||||
(.*?) # style names - ungreedy - second group
|
||||
> # literal
|
||||
""", re.X)
|
||||
def get_max_width() -> int:
|
||||
return click.get_current_context().max_content_width or DEFAULT_WIDTH
|
||||
|
||||
|
||||
def colorize(message):
|
||||
"""
|
||||
Replaces style tags in `message` with ANSI escape codes.
|
||||
|
||||
Markup is inspired by HTML, but you can use multiple words pre tag, e.g.:
|
||||
|
||||
<red bold>alert!</red bold> a thing happened
|
||||
|
||||
Empty closing tag will reset all styes:
|
||||
|
||||
<red bold>alert!</> a thing happened
|
||||
|
||||
Styles can be nested:
|
||||
|
||||
<red>red <underline>red and underline</underline> red</red>
|
||||
"""
|
||||
|
||||
def _codes(styles):
|
||||
for style in styles:
|
||||
yield STYLES.get(style, "")
|
||||
|
||||
def _generator(message):
|
||||
# A list is used instead of a set because we want to keep style order
|
||||
# This allows nesting colors, e.g. "<blue>foo<red>bar</red>baz</blue>"
|
||||
position = 0
|
||||
active_styles = []
|
||||
|
||||
for match in re.finditer(STYLE_TAG_PATTERN, message):
|
||||
is_closing = bool(match.group(1))
|
||||
styles = match.group(2).strip().split()
|
||||
|
||||
start, end = match.span()
|
||||
# Replace backslash for escaped <
|
||||
yield message[position:start].replace("\\<", "<")
|
||||
|
||||
if is_closing:
|
||||
yield STYLES["reset"]
|
||||
|
||||
# Empty closing tag resets all styles
|
||||
if styles == []:
|
||||
active_styles = []
|
||||
else:
|
||||
active_styles = [s for s in active_styles if s not in styles]
|
||||
yield from _codes(active_styles)
|
||||
else:
|
||||
active_styles = active_styles + styles
|
||||
yield from _codes(styles)
|
||||
|
||||
position = end
|
||||
|
||||
if position == 0:
|
||||
# Nothing matched, yield the original string
|
||||
yield message
|
||||
else:
|
||||
# Yield the remaining fragment
|
||||
yield message[position:]
|
||||
# Reset styles at the end to prevent leaking
|
||||
yield STYLES["reset"]
|
||||
|
||||
return "".join(_generator(message))
|
||||
def get_terminal_width() -> int:
|
||||
return shutil.get_terminal_size().columns
|
||||
|
||||
|
||||
def strip_tags(message):
|
||||
return re.sub(STYLE_TAG_PATTERN, "", message)
|
||||
def get_width() -> int:
|
||||
return min(get_terminal_width(), get_max_width())
|
||||
|
||||
|
||||
def use_ansi_color():
|
||||
"""Returns True if ANSI color codes should be used."""
|
||||
|
||||
# Windows doesn't support color unless ansicon is installed
|
||||
# See: http://adoxa.altervista.org/ansicon/
|
||||
if sys.platform == 'win32' and 'ANSICON' not in os.environ:
|
||||
return False
|
||||
|
||||
# Don't show color if stdout is not a tty, e.g. if output is piped on
|
||||
if not sys.stdout.isatty():
|
||||
return False
|
||||
|
||||
# Don't show color if explicitly specified in options
|
||||
if "--no-color" in sys.argv:
|
||||
return False
|
||||
|
||||
return True
|
||||
def print_warning(text: str):
|
||||
click.secho(f"Warning: {text}", fg="yellow", err=True)
|
||||
|
||||
|
||||
USE_ANSI_COLOR = use_ansi_color()
|
||||
|
||||
QUIET = "--quiet" in sys.argv
|
||||
def print_instance(instance: Instance):
|
||||
width = get_width()
|
||||
click.echo(instance_to_text(instance, width))
|
||||
|
||||
|
||||
def print_out(*args, **kwargs):
|
||||
if not QUIET:
|
||||
args = [colorize(a) if USE_ANSI_COLOR else strip_tags(a) for a in args]
|
||||
print(*args, **kwargs)
|
||||
def instance_to_text(instance: Instance, width: int) -> str:
|
||||
return "\n".join(instance_lines(instance, width))
|
||||
|
||||
|
||||
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 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 ""
|
||||
|
||||
|
||||
def print_instance(instance):
|
||||
print_out(f"<green>{instance['title']}</green>")
|
||||
print_out(f"<blue>{instance['uri']}</blue>")
|
||||
print_out(f"running Mastodon {instance['version']}")
|
||||
print_out()
|
||||
|
||||
description = instance.get("description")
|
||||
if description:
|
||||
for paragraph in re.split(r"[\r\n]+", description.strip()):
|
||||
if instance.description:
|
||||
for paragraph in re.split(r"[\r\n]+", instance.description.strip()):
|
||||
paragraph = get_text(paragraph)
|
||||
print_out(textwrap.fill(paragraph, width=80))
|
||||
print_out()
|
||||
yield textwrap.fill(paragraph, width=width)
|
||||
yield ""
|
||||
|
||||
rules = instance.get("rules")
|
||||
if rules:
|
||||
print_out("Rules:")
|
||||
for ordinal, rule in enumerate(rules):
|
||||
if instance.rules:
|
||||
yield "Rules:"
|
||||
for ordinal, rule in enumerate(instance.rules):
|
||||
ordinal = f"{ordinal + 1}."
|
||||
lines = textwrap.wrap(rule["text"], 80 - len(ordinal))
|
||||
lines = textwrap.wrap(rule.text, width - len(ordinal))
|
||||
first = True
|
||||
for line in lines:
|
||||
if first:
|
||||
print_out(f"{ordinal} {line}")
|
||||
yield f"{ordinal} {line}"
|
||||
first = False
|
||||
else:
|
||||
print_out(f"{' ' * len(ordinal)} {line}")
|
||||
yield f"{' ' * len(ordinal)} {line}"
|
||||
yield ""
|
||||
|
||||
contact = instance.contact_account
|
||||
if contact:
|
||||
yield f"Contact: {contact.display_name} @{contact.acct}"
|
||||
|
||||
|
||||
def print_account(account):
|
||||
print_out(f"<green>@{account['acct']}</green> {account['display_name']}")
|
||||
|
||||
if account["note"]:
|
||||
print_out("")
|
||||
print_html(account["note"])
|
||||
|
||||
print_out("")
|
||||
print_out(f"ID: <green>{account['id']}</green>")
|
||||
print_out(f"Since: <green>{account['created_at'][:10]}</green>")
|
||||
print_out("")
|
||||
print_out(f"Followers: <yellow>{account['followers_count']}</yellow>")
|
||||
print_out(f"Following: <yellow>{account['following_count']}</yellow>")
|
||||
print_out(f"Statuses: <yellow>{account['statuses_count']}</yellow>")
|
||||
|
||||
if account["fields"]:
|
||||
for field in account["fields"]:
|
||||
name = field["name"].title()
|
||||
print_out(f'\n<yellow>{name}</yellow>:')
|
||||
print_html(field["value"])
|
||||
if field["verified_at"]:
|
||||
print_out("<green>✓ Verified</green>")
|
||||
|
||||
print_out("")
|
||||
print_out(account["url"])
|
||||
def print_account(account: Account) -> None:
|
||||
width = get_width()
|
||||
click.echo(account_to_text(account, width))
|
||||
|
||||
|
||||
HASHTAG_PATTERN = re.compile(r'(?<!\w)(#\w+)\b')
|
||||
def account_to_text(account: Account, width: int) -> str:
|
||||
return "\n".join(account_lines(account, width))
|
||||
|
||||
|
||||
def highlight_hashtags(line):
|
||||
return re.sub(HASHTAG_PATTERN, '<cyan>\\1</cyan>', line)
|
||||
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:
|
||||
yield ""
|
||||
yield from html_lines(account.note, width)
|
||||
|
||||
yield ""
|
||||
yield f"ID: {green(account.id)}"
|
||||
yield f"Since: {green(since)}"
|
||||
yield ""
|
||||
yield f"Followers: {yellow(account.followers_count)}"
|
||||
yield f"Following: {yellow(account.following_count)}"
|
||||
yield f"Statuses: {yellow(account.statuses_count)}"
|
||||
|
||||
if account.fields:
|
||||
for field in account.fields:
|
||||
name = field.name.title()
|
||||
yield f'\n{yellow(name)}:'
|
||||
yield from html_lines(field.value, width)
|
||||
if field.verified_at:
|
||||
yield green("✓ Verified")
|
||||
|
||||
yield ""
|
||||
yield account.url
|
||||
|
||||
|
||||
def print_acct_list(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_tag_list(tags):
|
||||
if tags:
|
||||
for tag in tags:
|
||||
print_out(f"* <green>#{tag['name']}\t</green>{tag['url']}")
|
||||
for tag in tags:
|
||||
click.echo(f"* {format_tag_name(tag)}\t{tag['url']}")
|
||||
|
||||
|
||||
def print_lists(lists):
|
||||
headers = ["ID", "Title", "Replies"]
|
||||
data = [[lst["id"], lst["title"], lst["replies_policy"]] for lst in lists]
|
||||
print_table(headers, data)
|
||||
|
||||
|
||||
def print_table(headers: List[str], data: List[List[str]]):
|
||||
widths = [[len(cell) for cell in row] for row in data + [headers]]
|
||||
widths = [max(width) for width in zip(*widths)]
|
||||
|
||||
def print_row(row):
|
||||
for idx, cell in enumerate(row):
|
||||
width = widths[idx]
|
||||
click.echo(cell.ljust(width), nl=False)
|
||||
click.echo(" ", nl=False)
|
||||
click.echo()
|
||||
|
||||
underlines = ["-" * width for width in widths]
|
||||
|
||||
print_row(headers)
|
||||
print_row(underlines)
|
||||
|
||||
for row in data:
|
||||
print_row(row)
|
||||
|
||||
|
||||
def print_list_accounts(accounts):
|
||||
if accounts:
|
||||
click.echo("Accounts in list:\n")
|
||||
print_acct_list(accounts)
|
||||
else:
|
||||
print_out("You're not following any hashtags.")
|
||||
click.echo("This list has no accounts.")
|
||||
|
||||
|
||||
def print_search_results(results):
|
||||
accounts = results['accounts']
|
||||
hashtags = results['hashtags']
|
||||
accounts = results["accounts"]
|
||||
hashtags = results["hashtags"]
|
||||
|
||||
if accounts:
|
||||
print_out("\nAccounts:")
|
||||
click.echo("\nAccounts:")
|
||||
print_acct_list(accounts)
|
||||
|
||||
if hashtags:
|
||||
print_out("\nHashtags:")
|
||||
print_out(", ".join([f"<green>#{t['name']}</green>" for t in hashtags]))
|
||||
click.echo("\nHashtags:")
|
||||
click.echo(", ".join([format_tag_name(tag) for tag in hashtags]))
|
||||
|
||||
if not accounts and not hashtags:
|
||||
print_out("<yellow>Nothing found</yellow>")
|
||||
click.echo("Nothing found")
|
||||
|
||||
|
||||
def print_status(status, width):
|
||||
reblog = status['reblog']
|
||||
content = reblog['content'] if reblog else status['content']
|
||||
media_attachments = reblog['media_attachments'] if reblog else status['media_attachments']
|
||||
in_reply_to = status['in_reply_to_id']
|
||||
poll = reblog.get('poll') if reblog else status.get('poll')
|
||||
def print_status(status: Status) -> None:
|
||||
width = get_width()
|
||||
click.echo(status_to_text(status, width))
|
||||
|
||||
time = parse_datetime(status['created_at'])
|
||||
time = time.strftime('%Y-%m-%d %H:%M %Z')
|
||||
|
||||
username = "@" + status['account']['acct']
|
||||
def status_to_text(status: Status, width: int) -> str:
|
||||
return "\n".join(status_lines(status))
|
||||
|
||||
|
||||
def status_lines(status: Status) -> Generator[str, None, None]:
|
||||
width = get_width()
|
||||
status_id = status.id
|
||||
in_reply_to_id = status.in_reply_to_id
|
||||
reblogged_by = status.account if status.reblog else None
|
||||
status = status.original
|
||||
|
||||
time = status.created_at.strftime('%Y-%m-%d %H:%M %Z')
|
||||
username = "@" + status.account.acct
|
||||
spacing = width - wcswidth(username) - wcswidth(time) - 2
|
||||
|
||||
display_name = status['account']['display_name']
|
||||
display_name = status.account.display_name
|
||||
|
||||
if display_name:
|
||||
author = f"{green(display_name)} {blue(username)}"
|
||||
spacing -= wcswidth(display_name) + 1
|
||||
else:
|
||||
author = blue(username)
|
||||
|
||||
print_out(
|
||||
f"<green>{display_name}</green>" if display_name else "",
|
||||
f"<blue>{username}</blue>",
|
||||
" " * spacing,
|
||||
f"<yellow>{time}</yellow>",
|
||||
)
|
||||
spaces = " " * spacing
|
||||
yield f"{author} {spaces} {yellow(time)}"
|
||||
|
||||
print_out("")
|
||||
print_html(content, width)
|
||||
yield ""
|
||||
yield from html_lines(status.content, width)
|
||||
|
||||
if media_attachments:
|
||||
print_out("\nMedia:")
|
||||
for attachment in media_attachments:
|
||||
url = attachment["url"]
|
||||
if status.media_attachments:
|
||||
yield ""
|
||||
yield "Media:"
|
||||
for attachment in status.media_attachments:
|
||||
url = attachment.url
|
||||
for line in wc_wrap(url, width):
|
||||
print_out(line)
|
||||
yield line
|
||||
|
||||
if poll:
|
||||
print_poll(poll)
|
||||
if status.poll:
|
||||
yield from poll_lines(status.poll)
|
||||
|
||||
print_out()
|
||||
print_out(
|
||||
f"ID <yellow>{status['id']}</yellow> ",
|
||||
f"↲ In reply to <yellow>{in_reply_to}</yellow> " if in_reply_to else "",
|
||||
f"↻ Reblogged <blue>@{reblog['account']['acct']}</blue> " if reblog else "",
|
||||
)
|
||||
reblogged_by_acct = f"@{reblogged_by.acct}" if reblogged_by else None
|
||||
yield ""
|
||||
|
||||
reply = f"↲ In reply to {yellow(in_reply_to_id)} " if in_reply_to_id else ""
|
||||
boost = f"↻ {blue(reblogged_by_acct)} boosted " if reblogged_by else ""
|
||||
yield f"ID {yellow(status_id)} {reply} {boost}"
|
||||
|
||||
|
||||
def print_html(text, width=80):
|
||||
def html_lines(html: str, width: int) -> Generator[str, None, None]:
|
||||
first = True
|
||||
for paragraph in parse_html(text):
|
||||
for paragraph in html_to_paragraphs(html):
|
||||
if not first:
|
||||
print_out("")
|
||||
yield ""
|
||||
for line in paragraph:
|
||||
for subline in wc_wrap(line, width):
|
||||
print_out(highlight_hashtags(subline))
|
||||
yield subline
|
||||
first = False
|
||||
|
||||
|
||||
def print_poll(poll):
|
||||
print_out()
|
||||
for idx, option in enumerate(poll["options"]):
|
||||
perc = (round(100 * option["votes_count"] / poll["votes_count"])
|
||||
if poll["votes_count"] else 0)
|
||||
def poll_lines(poll: Poll) -> Generator[str, None, None]:
|
||||
for idx, option in enumerate(poll.options):
|
||||
perc = (round(100 * option.votes_count / poll.votes_count)
|
||||
if poll.votes_count and option.votes_count is not None else 0)
|
||||
|
||||
if poll["voted"] and poll["own_votes"] and idx in poll["own_votes"]:
|
||||
voted_for = " <yellow>✓</yellow>"
|
||||
if poll.voted and poll.own_votes and idx in poll.own_votes:
|
||||
voted_for = yellow(" ✓")
|
||||
else:
|
||||
voted_for = ""
|
||||
|
||||
print_out(f'{option["title"]} - {perc}% {voted_for}')
|
||||
yield f"{option.title} - {perc}% {voted_for}"
|
||||
|
||||
poll_footer = f'Poll · {poll["votes_count"]} votes'
|
||||
poll_footer = f'Poll · {poll.votes_count} votes'
|
||||
|
||||
if poll["expired"]:
|
||||
if poll.expired:
|
||||
poll_footer += " · Closed"
|
||||
|
||||
if poll["expires_at"]:
|
||||
expires_at = parse_datetime(poll["expires_at"]).strftime("%Y-%m-%d %H:%M")
|
||||
if poll.expires_at:
|
||||
expires_at = poll.expires_at.strftime("%Y-%m-%d %H:%M")
|
||||
poll_footer += f" · Closes on {expires_at}"
|
||||
|
||||
print_out()
|
||||
print_out(poll_footer)
|
||||
yield ""
|
||||
yield poll_footer
|
||||
|
||||
|
||||
def print_timeline(items, width=100):
|
||||
print_out("─" * width)
|
||||
def print_timeline(items: Iterable[Status]):
|
||||
print_divider()
|
||||
for item in items:
|
||||
print_status(item, width)
|
||||
print_out("─" * width)
|
||||
print_status(item)
|
||||
print_divider()
|
||||
|
||||
|
||||
notification_msgs = {
|
||||
"follow": "{account} now follows you",
|
||||
"mention": "{account} mentioned you in",
|
||||
"reblog": "{account} reblogged your status",
|
||||
"favourite": "{account} favourited your status",
|
||||
}
|
||||
def print_notification(notification: Notification):
|
||||
print_notification_header(notification)
|
||||
if notification.status:
|
||||
print_divider(char="-")
|
||||
print_status(notification.status)
|
||||
|
||||
|
||||
def print_notification(notification, width=100):
|
||||
account = "{display_name} @{acct}".format(**notification["account"])
|
||||
msg = notification_msgs.get(notification["type"])
|
||||
if msg is None:
|
||||
return
|
||||
|
||||
print_out("─" * width)
|
||||
print_out(msg.format(account=account))
|
||||
status = notification.get("status")
|
||||
if status is not None:
|
||||
print_status(status, width)
|
||||
|
||||
|
||||
def print_notifications(notifications, width=100):
|
||||
def print_notifications(notifications: List[Notification]):
|
||||
for notification in notifications:
|
||||
print_notification(notification)
|
||||
print_out("─" * width)
|
||||
if notification.type not in ['pleroma:emoji_reaction']:
|
||||
print_divider()
|
||||
print_notification(notification)
|
||||
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")
|
||||
|
61
toot/settings.py
Normal file
@ -0,0 +1,61 @@
|
||||
from functools import lru_cache
|
||||
from os.path import exists, join
|
||||
from tomlkit import parse
|
||||
from toot import get_config_dir
|
||||
from typing import Optional, Type, TypeVar
|
||||
|
||||
|
||||
DISABLE_SETTINGS = False
|
||||
|
||||
TOOT_SETTINGS_FILE_NAME = "settings.toml"
|
||||
|
||||
|
||||
def get_settings_path():
|
||||
return join(get_config_dir(), TOOT_SETTINGS_FILE_NAME)
|
||||
|
||||
|
||||
def _load_settings() -> dict:
|
||||
# Used for testing without config file
|
||||
if DISABLE_SETTINGS:
|
||||
return {}
|
||||
|
||||
path = get_settings_path()
|
||||
|
||||
if not exists(path):
|
||||
return {}
|
||||
|
||||
with open(path) as f:
|
||||
return parse(f.read())
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def get_settings():
|
||||
return _load_settings()
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def get_setting(key: str, type: Type[T], default: Optional[T] = None) -> Optional[T]:
|
||||
"""
|
||||
Get a setting value. The key should be a dot-separated string,
|
||||
e.g. "commands.post.editor" which will correspond to the "editor" setting
|
||||
inside the `[commands.post]` section.
|
||||
"""
|
||||
settings = get_settings()
|
||||
return _get_setting(settings, key.split("."), type, default)
|
||||
|
||||
|
||||
def _get_setting(dct, keys, type: Type, default=None):
|
||||
if len(keys) == 0:
|
||||
if isinstance(dct, type):
|
||||
return dct
|
||||
else:
|
||||
# TODO: warn? cast? both?
|
||||
return default
|
||||
|
||||
key = keys[0]
|
||||
if isinstance(dct, dict) and key in dct:
|
||||
return _get_setting(dct[key], keys[1:], type, default)
|
||||
|
||||
return default
|
411
toot/tui/app.py
@ -1,25 +1,43 @@
|
||||
import logging
|
||||
import subprocess
|
||||
import urwid
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import NamedTuple, Optional
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from toot import api, config, __version__
|
||||
from toot.console import get_default_visibility
|
||||
from toot import api, config, __version__, settings
|
||||
from toot import App, User
|
||||
from toot.cli import get_default_visibility
|
||||
from toot.exceptions import ApiError
|
||||
from toot.utils.datetime import parse_datetime
|
||||
|
||||
from .compose import StatusComposer
|
||||
from .constants import PALETTE
|
||||
from .entities import Status
|
||||
from .overlays import ExceptionStackTrace, GotoMenu, Help, StatusSource, StatusLinks, StatusZoom
|
||||
from .overlays import StatusDeleteConfirmation
|
||||
from .overlays import StatusDeleteConfirmation, Account
|
||||
from .poll import Poll
|
||||
from .timeline import Timeline
|
||||
from .utils import parse_content_links, show_media
|
||||
from .utils import get_max_toot_chars, parse_content_links, copy_to_clipboard
|
||||
from .widgets import ModalBox, RoundedLineBox
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
urwid.set_encoding('UTF-8')
|
||||
|
||||
|
||||
DEFAULT_MAX_TOOT_CHARS = 500
|
||||
|
||||
|
||||
class TuiOptions(NamedTuple):
|
||||
colors: int
|
||||
media_viewer: Optional[str]
|
||||
always_show_sensitive: bool
|
||||
relative_datetimes: bool
|
||||
default_visibility: Optional[bool]
|
||||
|
||||
|
||||
class Header(urwid.WidgetWrap):
|
||||
def __init__(self, app, user):
|
||||
self.app = app
|
||||
@ -71,29 +89,41 @@ class Footer(urwid.Pile):
|
||||
|
||||
class TUI(urwid.Frame):
|
||||
"""Main TUI frame."""
|
||||
loop: urwid.MainLoop
|
||||
screen: urwid.BaseScreen
|
||||
|
||||
@classmethod
|
||||
def create(cls, app, user, args):
|
||||
@staticmethod
|
||||
def create(app: App, user: User, args: TuiOptions):
|
||||
"""Factory method, sets up TUI and an event loop."""
|
||||
screen = urwid.raw_display.Screen()
|
||||
screen.set_terminal_properties(args.colors)
|
||||
|
||||
tui = TUI(app, user, screen, args)
|
||||
|
||||
palette = PALETTE.copy()
|
||||
overrides = settings.get_setting("tui.palette", dict, {})
|
||||
for name, styles in overrides.items():
|
||||
palette.append(tuple([name] + styles))
|
||||
|
||||
tui = cls(app, user, args)
|
||||
loop = urwid.MainLoop(
|
||||
tui,
|
||||
palette=PALETTE,
|
||||
palette=palette,
|
||||
event_loop=urwid.AsyncioEventLoop(),
|
||||
unhandled_input=tui.unhandled_input,
|
||||
screen=screen,
|
||||
)
|
||||
tui.loop = loop
|
||||
|
||||
return tui
|
||||
|
||||
def __init__(self, app, user, args):
|
||||
def __init__(self, app, user, screen, options: TuiOptions):
|
||||
self.app = app
|
||||
self.user = user
|
||||
self.args = args
|
||||
self.config = config.load_config()
|
||||
self.options = options
|
||||
|
||||
self.loop = None # set in `create`
|
||||
self.loop = None # late init, set in `create`
|
||||
self.screen = screen
|
||||
self.executor = ThreadPoolExecutor(max_workers=1)
|
||||
self.timeline_generator = api.home_timeline_generator(app, user, limit=40)
|
||||
|
||||
@ -104,20 +134,24 @@ class TUI(urwid.Frame):
|
||||
self.footer.set_status("Loading...")
|
||||
|
||||
# Default max status length, updated on startup
|
||||
self.max_toot_chars = 500
|
||||
self.max_toot_chars = DEFAULT_MAX_TOOT_CHARS
|
||||
|
||||
self.timeline = None
|
||||
self.overlay = None
|
||||
self.exception = None
|
||||
self.can_translate = False
|
||||
self.account = None
|
||||
self.followed_accounts = []
|
||||
self.preferences = {}
|
||||
|
||||
super().__init__(self.body, header=self.header, footer=self.footer)
|
||||
|
||||
def run(self):
|
||||
self.loop.set_alarm_in(0, lambda *args: self.async_load_instance())
|
||||
self.loop.set_alarm_in(0, lambda *args: self.async_load_followed_tags())
|
||||
self.loop.set_alarm_in(0, lambda *args: self.async_load_preferences())
|
||||
self.loop.set_alarm_in(0, lambda *args: self.async_load_timeline(
|
||||
is_initial=True, timeline_name="home"))
|
||||
self.loop.set_alarm_in(0, lambda *args: self.async_load_followed_accounts())
|
||||
self.loop.run()
|
||||
self.executor.shutdown(wait=False)
|
||||
|
||||
@ -145,8 +179,8 @@ class TUI(urwid.Frame):
|
||||
|
||||
return urwid.Filler(intro)
|
||||
|
||||
def run_in_thread(self, fn, args=[], kwargs={}, done_callback=None, error_callback=None):
|
||||
"""Runs `fn(*args, **kwargs)` asynchronously in a separate thread.
|
||||
def run_in_thread(self, fn, done_callback=None, error_callback=None):
|
||||
"""Runs `fn` asynchronously in a separate thread.
|
||||
|
||||
On completion calls `done_callback` if `fn` exited cleanly, or
|
||||
`error_callback` if an exception was caught. Callback methods are
|
||||
@ -155,7 +189,7 @@ class TUI(urwid.Frame):
|
||||
|
||||
def _default_error_callback(ex):
|
||||
self.exception = ex
|
||||
self.footer.set_error_message("An exception occurred, press E to view")
|
||||
self.footer.set_error_message("An exception occurred, press X to view")
|
||||
|
||||
_error_callback = error_callback or _default_error_callback
|
||||
|
||||
@ -170,53 +204,15 @@ class TUI(urwid.Frame):
|
||||
logger.exception(exception)
|
||||
self.loop.set_alarm_in(0, lambda *args: _error_callback(exception))
|
||||
|
||||
future = self.executor.submit(fn, *args, **kwargs)
|
||||
# TODO: replace by `self.loop.event_loop.run_in_executor` at some point
|
||||
# Added in https://github.com/urwid/urwid/issues/575
|
||||
# Not yet released at the time of this comment
|
||||
future = self.loop.event_loop._loop.run_in_executor(self.executor, fn)
|
||||
future.add_done_callback(_done)
|
||||
return future
|
||||
|
||||
def connect_default_timeline_signals(self, timeline):
|
||||
def _compose(*args):
|
||||
self.show_compose()
|
||||
|
||||
def _delete(timeline, status):
|
||||
if status.is_mine:
|
||||
self.show_delete_confirmation(status)
|
||||
|
||||
def _reply(timeline, status):
|
||||
self.show_compose(status)
|
||||
|
||||
def _source(timeline, status):
|
||||
self.show_status_source(status)
|
||||
|
||||
def _links(timeline, status):
|
||||
self.show_links(status)
|
||||
|
||||
def _media(timeline, status):
|
||||
self.show_media(status)
|
||||
|
||||
def _menu(timeline, status):
|
||||
self.show_context_menu(status)
|
||||
|
||||
def _zoom(timeline, status_details):
|
||||
self.show_status_zoom(status_details)
|
||||
|
||||
def _clear(*args):
|
||||
self.clear_screen()
|
||||
|
||||
urwid.connect_signal(timeline, "bookmark", self.async_toggle_bookmark)
|
||||
urwid.connect_signal(timeline, "compose", _compose)
|
||||
urwid.connect_signal(timeline, "delete", _delete)
|
||||
urwid.connect_signal(timeline, "favourite", self.async_toggle_favourite)
|
||||
urwid.connect_signal(timeline, "focus", self.refresh_footer)
|
||||
urwid.connect_signal(timeline, "media", _media)
|
||||
urwid.connect_signal(timeline, "menu", _menu)
|
||||
urwid.connect_signal(timeline, "reblog", self.async_toggle_reblog)
|
||||
urwid.connect_signal(timeline, "reply", _reply)
|
||||
urwid.connect_signal(timeline, "source", _source)
|
||||
urwid.connect_signal(timeline, "links", _links)
|
||||
urwid.connect_signal(timeline, "zoom", _zoom)
|
||||
urwid.connect_signal(timeline, "translate", self.async_translate)
|
||||
urwid.connect_signal(timeline, "clear-screen", _clear)
|
||||
|
||||
def build_timeline(self, name, statuses, local):
|
||||
def _close(*args):
|
||||
@ -225,9 +221,6 @@ class TUI(urwid.Frame):
|
||||
def _next(*args):
|
||||
self.async_load_timeline(is_initial=False)
|
||||
|
||||
def _thread(timeline, status):
|
||||
self.show_thread(status)
|
||||
|
||||
def _toggle_save(timeline, status):
|
||||
if not timeline.name.startswith("#"):
|
||||
return
|
||||
@ -243,12 +236,11 @@ class TUI(urwid.Frame):
|
||||
self.loop.set_alarm_in(5, lambda *args: self.footer.clear_message())
|
||||
config.save_config(self.config)
|
||||
|
||||
timeline = Timeline(name, statuses, self.can_translate, self.followed_tags)
|
||||
timeline = Timeline(self, name, statuses)
|
||||
|
||||
self.connect_default_timeline_signals(timeline)
|
||||
urwid.connect_signal(timeline, "next", _next)
|
||||
urwid.connect_signal(timeline, "close", _close)
|
||||
urwid.connect_signal(timeline, "thread", _thread)
|
||||
urwid.connect_signal(timeline, "save", _toggle_save)
|
||||
|
||||
return timeline
|
||||
@ -266,19 +258,18 @@ class TUI(urwid.Frame):
|
||||
|
||||
# This is pretty fast, so it's probably ok to block while context is
|
||||
# loaded, can be made async later if needed
|
||||
context = api.context(self.app, self.user, status.original.id)
|
||||
context = api.context(self.app, self.user, status.original.id).json()
|
||||
ancestors = [self.make_status(s) for s in context["ancestors"]]
|
||||
descendants = [self.make_status(s) for s in context["descendants"]]
|
||||
statuses = ancestors + [status] + descendants
|
||||
focus = len(ancestors)
|
||||
|
||||
timeline = Timeline("thread", statuses, self.can_translate,
|
||||
self.followed_tags, focus, is_thread=True)
|
||||
timeline = Timeline(self, "thread", statuses, focus=focus, is_thread=True)
|
||||
|
||||
self.connect_default_timeline_signals(timeline)
|
||||
urwid.connect_signal(timeline, "close", _close)
|
||||
|
||||
self.body = timeline
|
||||
timeline.refresh_status_details()
|
||||
self.refresh_footer(timeline)
|
||||
|
||||
def async_load_timeline(self, is_initial, timeline_name=None, local=None):
|
||||
@ -322,11 +313,11 @@ class TUI(urwid.Frame):
|
||||
See: https://github.com/mastodon/mastodon/issues/19328
|
||||
"""
|
||||
def _load_instance():
|
||||
return api.get_instance(self.app.instance)
|
||||
return api.get_instance(self.app.base_url).json()
|
||||
|
||||
def _done(instance):
|
||||
if "max_toot_chars" in instance:
|
||||
self.max_toot_chars = instance["max_toot_chars"]
|
||||
self.max_toot_chars = get_max_toot_chars(instance, DEFAULT_MAX_TOOT_CHARS)
|
||||
logger.info(f"Max toot chars set to: {self.max_toot_chars}")
|
||||
|
||||
if "translation" in instance:
|
||||
# instance is advertising translation service
|
||||
@ -342,21 +333,33 @@ class TUI(urwid.Frame):
|
||||
|
||||
return self.run_in_thread(_load_instance, done_callback=_done)
|
||||
|
||||
def async_load_followed_tags(self):
|
||||
def _load_tag_list():
|
||||
def async_load_preferences(self):
|
||||
"""
|
||||
Attempt to update user preferences from instance.
|
||||
https://docs.joinmastodon.org/methods/preferences/
|
||||
"""
|
||||
def _load_preferences():
|
||||
return api.get_preferences(self.app, self.user).json()
|
||||
|
||||
def _done(preferences):
|
||||
self.preferences = preferences
|
||||
|
||||
return self.run_in_thread(_load_preferences, done_callback=_done)
|
||||
|
||||
def async_load_followed_accounts(self):
|
||||
def _load_accounts():
|
||||
try:
|
||||
return api.followed_tags(self.app, self.user)
|
||||
acct = f'@{self.user.username}@{self.user.instance}'
|
||||
self.account = api.find_account(self.app, self.user, acct)
|
||||
return api.following(self.app, self.user, self.account["id"])
|
||||
except ApiError:
|
||||
# not supported by all Mastodon servers so fail silently if necessary
|
||||
return []
|
||||
|
||||
def _done_tag_list(tags):
|
||||
if len(tags) > 0:
|
||||
self.followed_tags = [t["name"] for t in tags]
|
||||
else:
|
||||
self.followed_tags = []
|
||||
def _done_accounts(accounts):
|
||||
self.followed_accounts = {a["acct"] for a in accounts}
|
||||
|
||||
self.run_in_thread(_load_tag_list, done_callback=_done_tag_list)
|
||||
self.run_in_thread(_load_accounts, done_callback=_done_accounts)
|
||||
|
||||
def refresh_footer(self, timeline):
|
||||
"""Show status details in footer."""
|
||||
@ -373,11 +376,11 @@ class TUI(urwid.Frame):
|
||||
)
|
||||
|
||||
def clear_screen(self):
|
||||
self.loop.screen.clear()
|
||||
self.screen.clear()
|
||||
|
||||
def show_links(self, status):
|
||||
links = parse_content_links(status.data["content"]) if status else []
|
||||
post_attachments = status.data["media_attachments"] or []
|
||||
links = parse_content_links(status.original.data["content"]) if status else []
|
||||
post_attachments = status.original.data["media_attachments"] or []
|
||||
reblog_attachments = (status.data["reblog"]["media_attachments"] if status.data["reblog"] else None) or []
|
||||
|
||||
for a in post_attachments + reblog_attachments:
|
||||
@ -388,6 +391,8 @@ class TUI(urwid.Frame):
|
||||
self.clear_screen()
|
||||
|
||||
if links:
|
||||
links = list(set(links)) # deduplicate links
|
||||
links = sorted(links, key=lambda link: link[0]) # sort alphabetically by URL
|
||||
sl_widget = StatusLinks(links)
|
||||
urwid.connect_signal(sl_widget, "clear-screen", _clear)
|
||||
self.open_overlay(
|
||||
@ -415,32 +420,81 @@ class TUI(urwid.Frame):
|
||||
def _post(timeline, *args):
|
||||
self.post_status(*args)
|
||||
|
||||
composer = StatusComposer(self.max_toot_chars, self.user.username, in_reply_to)
|
||||
# If the user specified --default-visibility, use that; otherwise,
|
||||
# try to use the server-side default visibility. If that fails, fall
|
||||
# back to get_default_visibility().
|
||||
visibility = (self.options.default_visibility or
|
||||
self.preferences.get('posting:default:visibility',
|
||||
get_default_visibility()))
|
||||
|
||||
composer = StatusComposer(self.max_toot_chars, self.user.username,
|
||||
visibility, in_reply_to)
|
||||
urwid.connect_signal(composer, "close", _close)
|
||||
urwid.connect_signal(composer, "post", _post)
|
||||
self.open_overlay(composer, title="Compose status")
|
||||
|
||||
def async_edit(self, status):
|
||||
def _fetch_source():
|
||||
return api.fetch_status_source(self.app, self.user, status.id).json()
|
||||
|
||||
def _done(source):
|
||||
self.close_overlay()
|
||||
self.show_edit(status, source)
|
||||
|
||||
please_wait = ModalBox("Loading status...")
|
||||
self.open_overlay(please_wait)
|
||||
|
||||
self.run_in_thread(_fetch_source, done_callback=_done)
|
||||
|
||||
def show_edit(self, status, source):
|
||||
def _close(*args):
|
||||
self.close_overlay()
|
||||
|
||||
def _edit(timeline, *args):
|
||||
self.edit_status(status, *args)
|
||||
|
||||
composer = StatusComposer(self.max_toot_chars, self.user.username,
|
||||
visibility=None, edit=status, source=source)
|
||||
urwid.connect_signal(composer, "close", _close)
|
||||
urwid.connect_signal(composer, "post", _edit)
|
||||
self.open_overlay(composer, title="Edit status")
|
||||
|
||||
def show_goto_menu(self):
|
||||
user_timelines = self.config.get("timelines", {})
|
||||
menu = GotoMenu(user_timelines)
|
||||
user_lists = api.get_lists(self.app, self.user) or []
|
||||
|
||||
menu = GotoMenu(user_timelines, user_lists)
|
||||
urwid.connect_signal(menu, "home_timeline",
|
||||
lambda x: self.goto_home_timeline())
|
||||
urwid.connect_signal(menu, "public_timeline",
|
||||
lambda x, local: self.goto_public_timeline(local))
|
||||
urwid.connect_signal(menu, "bookmark_timeline",
|
||||
lambda x, local: self.goto_bookmarks())
|
||||
|
||||
urwid.connect_signal(menu, "notification_timeline",
|
||||
lambda x, local: self.goto_notifications())
|
||||
urwid.connect_signal(menu, "conversation_timeline",
|
||||
lambda x, local: self.goto_conversations())
|
||||
urwid.connect_signal(menu, "personal_timeline",
|
||||
lambda x, local: self.goto_personal_timeline())
|
||||
urwid.connect_signal(menu, "hashtag_timeline",
|
||||
lambda x, tag, local: self.goto_tag_timeline(tag, local=local))
|
||||
urwid.connect_signal(menu, "list_timeline",
|
||||
lambda x, list_item: self.goto_list_timeline(list_item))
|
||||
|
||||
self.open_overlay(menu, title="Go to", options=dict(
|
||||
align="center", width=("relative", 60),
|
||||
valign="middle", height=10 + len(user_timelines),
|
||||
valign="middle", height=18 + len(user_timelines) + len(user_lists),
|
||||
))
|
||||
|
||||
def show_help(self):
|
||||
self.open_overlay(Help(), title="Help")
|
||||
|
||||
def show_poll(self, status):
|
||||
self.open_overlay(
|
||||
widget=Poll(self.app, self.user, status),
|
||||
title="Poll",
|
||||
)
|
||||
|
||||
def goto_home_timeline(self):
|
||||
self.timeline_generator = api.home_timeline_generator(
|
||||
self.app, self.user, limit=40)
|
||||
@ -450,7 +504,8 @@ class TUI(urwid.Frame):
|
||||
def goto_public_timeline(self, local):
|
||||
self.timeline_generator = api.public_timeline_generator(
|
||||
self.app, self.user, local=local, limit=40)
|
||||
promise = self.async_load_timeline(is_initial=True, timeline_name="public")
|
||||
timeline_name = "local public" if local else "global public"
|
||||
promise = self.async_load_timeline(is_initial=True, timeline_name=timeline_name)
|
||||
promise.add_done_callback(lambda *args: self.close_overlay())
|
||||
|
||||
def goto_bookmarks(self):
|
||||
@ -459,6 +514,21 @@ class TUI(urwid.Frame):
|
||||
promise = self.async_load_timeline(is_initial=True, timeline_name="bookmarks")
|
||||
promise.add_done_callback(lambda *args: self.close_overlay())
|
||||
|
||||
def goto_notifications(self):
|
||||
self.timeline_generator = api.notification_timeline_generator(
|
||||
self.app, self.user, limit=40)
|
||||
promise = self.async_load_timeline(is_initial=True, timeline_name="notifications")
|
||||
promise.add_done_callback(lambda *args: self.close_overlay())
|
||||
|
||||
def goto_conversations(self):
|
||||
self.timeline_generator = api.conversation_timeline_generator(
|
||||
self.app, self.user, limit=40
|
||||
)
|
||||
promise = self.async_load_timeline(
|
||||
is_initial=True, timeline_name="conversations"
|
||||
)
|
||||
promise.add_done_callback(lambda *args: self.close_overlay())
|
||||
|
||||
def goto_tag_timeline(self, tag, local):
|
||||
self.timeline_generator = api.tag_timeline_generator(
|
||||
self.app, self.user, tag, local=local, limit=40)
|
||||
@ -467,10 +537,37 @@ class TUI(urwid.Frame):
|
||||
)
|
||||
promise.add_done_callback(lambda *args: self.close_overlay())
|
||||
|
||||
def goto_personal_timeline(self):
|
||||
account_name = f"{self.user.username}@{self.user.instance}"
|
||||
|
||||
self.timeline_generator = api.account_timeline_generator(
|
||||
self.app, self.user, account_name, reblogs=True, limit=40)
|
||||
promise = self.async_load_timeline(is_initial=True, timeline_name=f"personal {account_name}")
|
||||
promise.add_done_callback(lambda *args: self.close_overlay())
|
||||
|
||||
def goto_list_timeline(self, list_item):
|
||||
self.timeline_generator = api.timeline_list_generator(
|
||||
self.app, self.user, list_item['id'], limit=40)
|
||||
promise = self.async_load_timeline(
|
||||
is_initial=True, timeline_name=f"\N{clipboard}{list_item['title']}")
|
||||
promise.add_done_callback(lambda *args: self.close_overlay())
|
||||
|
||||
def show_media(self, status):
|
||||
urls = [m["url"] for m in status.original.data["media_attachments"]]
|
||||
if urls:
|
||||
show_media(urls)
|
||||
if not urls:
|
||||
return
|
||||
|
||||
media_viewer = self.options.media_viewer
|
||||
if media_viewer:
|
||||
try:
|
||||
subprocess.run([media_viewer] + urls)
|
||||
except FileNotFoundError:
|
||||
self.footer.set_error_message(f"Media viewer not found: '{media_viewer}'")
|
||||
except Exception as ex:
|
||||
self.exception = ex
|
||||
self.footer.set_error_message("Failed invoking media viewer. Press X to see exception.")
|
||||
else:
|
||||
self.footer.set_error_message("Media viewer not configured")
|
||||
|
||||
def show_context_menu(self, status):
|
||||
# TODO: show context menu
|
||||
@ -488,15 +585,20 @@ class TUI(urwid.Frame):
|
||||
urwid.connect_signal(widget, "close", _close)
|
||||
urwid.connect_signal(widget, "delete", _delete)
|
||||
self.open_overlay(widget, title="Delete status?", options=dict(
|
||||
align="center", width=("relative", 60),
|
||||
valign="middle", height=5,
|
||||
align="center", width=30,
|
||||
valign="middle", height=4,
|
||||
))
|
||||
|
||||
def post_status(self, content, warning, visibility, in_reply_to_id):
|
||||
data = api.post_status(self.app, self.user, content,
|
||||
data = api.post_status(
|
||||
self.app,
|
||||
self.user,
|
||||
content,
|
||||
spoiler_text=warning,
|
||||
visibility=visibility,
|
||||
in_reply_to_id=in_reply_to_id)
|
||||
in_reply_to_id=in_reply_to_id
|
||||
).json()
|
||||
|
||||
status = self.make_status(data)
|
||||
|
||||
# TODO: fetch new items from the timeline?
|
||||
@ -504,13 +606,55 @@ class TUI(urwid.Frame):
|
||||
self.footer.set_message("Status posted {} \\o/".format(status.id))
|
||||
self.close_overlay()
|
||||
|
||||
def edit_status(self, status, content, warning, visibility, in_reply_to_id):
|
||||
# We don't support editing polls (yet), so to avoid losing the poll
|
||||
# data from the original toot, copy it to the edit request.
|
||||
poll_args = {}
|
||||
poll = status.original.data.get('poll', None)
|
||||
|
||||
if poll is not None:
|
||||
poll_args['poll_options'] = [o['title'] for o in poll['options']]
|
||||
poll_args['poll_multiple'] = poll['multiple']
|
||||
|
||||
# Convert absolute expiry time into seconds from now.
|
||||
expires_at = parse_datetime(poll['expires_at'])
|
||||
expires_in = int((expires_at - datetime.now(timezone.utc)).total_seconds())
|
||||
poll_args['poll_expires_in'] = expires_in
|
||||
|
||||
if 'hide_totals' in poll:
|
||||
poll_args['poll_hide_totals'] = poll['hide_totals']
|
||||
|
||||
data = api.edit_status(
|
||||
self.app,
|
||||
self.user,
|
||||
status.id,
|
||||
content,
|
||||
spoiler_text=warning,
|
||||
visibility=visibility,
|
||||
**poll_args
|
||||
).json()
|
||||
|
||||
new_status = self.make_status(data)
|
||||
|
||||
self.footer.set_message("Status edited {} \\o/".format(status.id))
|
||||
self.close_overlay()
|
||||
|
||||
if self.timeline is not None:
|
||||
self.timeline.update_status(new_status)
|
||||
|
||||
def show_account(self, account_id):
|
||||
account = api.whois(self.app, self.user, account_id)
|
||||
relationship = api.get_relationship(self.app, self.user, account_id)
|
||||
self.open_overlay(
|
||||
widget=Account(self.app, self.user, account, relationship),
|
||||
title="Account",
|
||||
)
|
||||
|
||||
def async_toggle_favourite(self, timeline, status):
|
||||
def _favourite():
|
||||
logger.info("Favouriting {}".format(status))
|
||||
api.favourite(self.app, self.user, status.id)
|
||||
|
||||
def _unfavourite():
|
||||
logger.info("Unfavouriting {}".format(status))
|
||||
api.unfavourite(self.app, self.user, status.id)
|
||||
|
||||
def _done(loop):
|
||||
@ -527,18 +671,16 @@ class TUI(urwid.Frame):
|
||||
|
||||
def async_toggle_reblog(self, timeline, status):
|
||||
def _reblog():
|
||||
logger.info("Reblogging {}".format(status))
|
||||
api.reblog(self.app, self.user, status.id, visibility=get_default_visibility())
|
||||
api.reblog(self.app, self.user, status.original.id, visibility=get_default_visibility())
|
||||
|
||||
def _unreblog():
|
||||
logger.info("Unreblogging {}".format(status))
|
||||
api.unreblog(self.app, self.user, status.id)
|
||||
api.unreblog(self.app, self.user, status.original.id)
|
||||
|
||||
def _done(loop):
|
||||
# Create a new Status with flipped reblogged flag
|
||||
new_data = status.data
|
||||
new_data["reblogged"] = not status.reblogged
|
||||
new_status = self.make_status(new_data)
|
||||
new_status.original.reblogged = not status.original.reblogged
|
||||
timeline.update_status(new_status)
|
||||
|
||||
# Check if status is rebloggable
|
||||
@ -549,17 +691,16 @@ class TUI(urwid.Frame):
|
||||
return
|
||||
|
||||
self.run_in_thread(
|
||||
_unreblog if status.reblogged else _reblog,
|
||||
_unreblog if status.original.reblogged else _reblog,
|
||||
done_callback=_done
|
||||
)
|
||||
|
||||
def async_translate(self, timeline, status):
|
||||
def _translate():
|
||||
logger.info("Translating {}".format(status))
|
||||
self.footer.set_message("Translating status {}".format(status.id))
|
||||
self.footer.set_message("Translating status {}".format(status.original.id))
|
||||
|
||||
try:
|
||||
response = api.translate(self.app, self.user, status.id)
|
||||
response = api.translate(self.app, self.user, status.original.id)
|
||||
if response["content"]:
|
||||
self.footer.set_message("Status translated")
|
||||
else:
|
||||
@ -574,25 +715,23 @@ class TUI(urwid.Frame):
|
||||
|
||||
def _done(response):
|
||||
if response is not None:
|
||||
status.translation = response["content"]
|
||||
status.translated_from = response["detected_source_language"]
|
||||
status.show_translation = True
|
||||
status.original.translation = response["content"]
|
||||
status.original.translated_from = response["detected_source_language"]
|
||||
status.original.show_translation = True
|
||||
timeline.update_status(status)
|
||||
|
||||
# If already translated, toggle showing translation
|
||||
if status.translation:
|
||||
status.show_translation = not status.show_translation
|
||||
if status.original.translation:
|
||||
status.original.show_translation = not status.original.show_translation
|
||||
timeline.update_status(status)
|
||||
else:
|
||||
self.run_in_thread(_translate, done_callback=_done)
|
||||
|
||||
def async_toggle_bookmark(self, timeline, status):
|
||||
def _bookmark():
|
||||
logger.info("Bookmarking {}".format(status))
|
||||
api.bookmark(self.app, self.user, status.id)
|
||||
|
||||
def _unbookmark():
|
||||
logger.info("Unbookmarking {}".format(status))
|
||||
api.unbookmark(self.app, self.user, status.id)
|
||||
|
||||
def _done(loop):
|
||||
@ -616,6 +755,12 @@ class TUI(urwid.Frame):
|
||||
|
||||
return self.run_in_thread(_delete, done_callback=_done)
|
||||
|
||||
def copy_status(self, status):
|
||||
# TODO: copy a better version of status content
|
||||
# including URLs
|
||||
copy_to_clipboard(self.screen, status.original.data["content"])
|
||||
self.footer.set_message(f"Status {status.original.id} copied")
|
||||
|
||||
# --- Overlay handling -----------------------------------------------------
|
||||
|
||||
default_overlay_options = dict(
|
||||
@ -624,7 +769,7 @@ class TUI(urwid.Frame):
|
||||
)
|
||||
|
||||
def open_overlay(self, widget, options={}, title=""):
|
||||
top_widget = urwid.LineBox(widget, title=title)
|
||||
top_widget = RoundedLineBox(widget, title=title)
|
||||
bottom_widget = self.body
|
||||
|
||||
_options = self.default_overlay_options.copy()
|
||||
@ -640,12 +785,46 @@ class TUI(urwid.Frame):
|
||||
def close_overlay(self):
|
||||
self.body = self.overlay.bottom_w
|
||||
self.overlay = None
|
||||
if self.timeline:
|
||||
self.timeline.refresh_status_details()
|
||||
|
||||
def refresh_timeline(self):
|
||||
# No point in refreshing the bookmarks timeline
|
||||
# and we don't have a good way to refresh a
|
||||
# list timeline yet (no reference to list ID kept)
|
||||
if (not self.timeline
|
||||
or self.timeline.name == 'bookmarks'
|
||||
or self.timeline.name.startswith("\N{clipboard}")):
|
||||
return
|
||||
|
||||
if self.timeline.name.startswith("#"):
|
||||
self.timeline_generator = api.tag_timeline_generator(
|
||||
self.app, self.user, self.timeline.name[1:], limit=40)
|
||||
elif self.timeline.name.startswith("\N{clipboard}"):
|
||||
self.timeline_generator = api.tag_timeline_generator(
|
||||
self.app, self.user, self.timeline.name[1:], limit=40)
|
||||
else:
|
||||
if self.timeline.name.endswith("public"):
|
||||
self.timeline_generator = api.public_timeline_generator(
|
||||
self.app, self.user, local=self.timeline.name.startswith("local"), limit=40)
|
||||
elif self.timeline.name == "notifications":
|
||||
self.timeline_generator = api.notification_timeline_generator(
|
||||
self.app, self.user, limit=40)
|
||||
elif self.timeline.name == "conversations":
|
||||
self.timeline_generator = api.conversation_timeline_generator(
|
||||
self.app, self.user, limit=40)
|
||||
else:
|
||||
# default to home timeline
|
||||
self.timeline_generator = api.home_timeline_generator(
|
||||
self.app, self.user, limit=40)
|
||||
|
||||
self.async_load_timeline(is_initial=True, timeline_name=self.timeline.name)
|
||||
|
||||
# --- Keys -----------------------------------------------------------------
|
||||
|
||||
def unhandled_input(self, key):
|
||||
# TODO: this should not be in unhandled input
|
||||
if key in ('e', 'E'):
|
||||
if key in ('x', 'X'):
|
||||
if self.exception:
|
||||
self.show_exception(self.exception)
|
||||
|
||||
@ -653,15 +832,13 @@ class TUI(urwid.Frame):
|
||||
if not self.overlay:
|
||||
self.show_goto_menu()
|
||||
|
||||
elif key in ('h', 'H'):
|
||||
elif key == '?':
|
||||
if not self.overlay:
|
||||
self.show_help()
|
||||
|
||||
elif key == ',':
|
||||
if not self.overlay:
|
||||
self.timeline_generator = api.home_timeline_generator(
|
||||
self.app, self.user, limit=40)
|
||||
self.async_load_timeline(is_initial=True, timeline_name=self.timeline.name)
|
||||
self.refresh_timeline()
|
||||
|
||||
elif key == 'esc':
|
||||
if self.overlay:
|
||||
|
@ -1,8 +1,6 @@
|
||||
import urwid
|
||||
import logging
|
||||
|
||||
from toot.console import get_default_visibility
|
||||
|
||||
from .constants import VISIBILITY_OPTIONS
|
||||
from .widgets import Button, EditBox
|
||||
|
||||
@ -11,21 +9,22 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class StatusComposer(urwid.Frame):
|
||||
"""
|
||||
UI for compose and posting a status message.
|
||||
UI for composing or editing a status message.
|
||||
|
||||
To edit a status, provide the original status in 'edit', and optionally
|
||||
provide the status source (from the /status/:id/source API endpoint) in
|
||||
'source'; this should have at least a 'text' member, and optionally
|
||||
'spoiler_text'. If source is not provided, the formatted HTML will be
|
||||
presented to the user for editing.
|
||||
"""
|
||||
signals = ["close", "post"]
|
||||
|
||||
def __init__(self, max_chars, username, in_reply_to=None):
|
||||
def __init__(self, max_chars, username, visibility, in_reply_to=None,
|
||||
edit=None, source=None):
|
||||
self.in_reply_to = in_reply_to
|
||||
self.max_chars = max_chars
|
||||
self.username = username
|
||||
|
||||
text = self.get_initial_text(in_reply_to)
|
||||
self.content_edit = EditBox(
|
||||
edit_text=text, edit_pos=len(text), multiline=True, allow_tab=True)
|
||||
urwid.connect_signal(self.content_edit.edit, "change", self.text_changed)
|
||||
|
||||
self.char_count = urwid.Text(["0/{}".format(max_chars)])
|
||||
self.edit = edit
|
||||
|
||||
self.cw_edit = None
|
||||
self.cw_add_button = Button("Add content warning",
|
||||
@ -33,11 +32,34 @@ class StatusComposer(urwid.Frame):
|
||||
self.cw_remove_button = Button("Remove content warning",
|
||||
on_press=self.remove_content_warning)
|
||||
|
||||
self.visibility = get_default_visibility()
|
||||
if edit:
|
||||
if source is None:
|
||||
text = edit.data["content"]
|
||||
else:
|
||||
text = source.get("text", edit.data["content"])
|
||||
|
||||
if 'spoiler_text' in source:
|
||||
self.cw_edit = EditBox(multiline=True, allow_tab=True,
|
||||
edit_text=source['spoiler_text'])
|
||||
|
||||
self.visibility = edit.data["visibility"]
|
||||
|
||||
else: # not edit
|
||||
text = self.get_initial_text(in_reply_to)
|
||||
self.visibility = (
|
||||
in_reply_to.visibility if in_reply_to else visibility
|
||||
)
|
||||
|
||||
self.content_edit = EditBox(
|
||||
edit_text=text, edit_pos=len(text), multiline=True, allow_tab=True)
|
||||
urwid.connect_signal(self.content_edit.edit, "change", self.text_changed)
|
||||
|
||||
self.char_count = urwid.Text(["0/{}".format(max_chars)])
|
||||
|
||||
self.visibility_button = Button("Visibility: {}".format(self.visibility),
|
||||
on_press=self.choose_visibility)
|
||||
|
||||
self.post_button = Button("Post", on_press=self.post)
|
||||
self.post_button = Button("Edit" if edit else "Post", on_press=self.post)
|
||||
self.cancel_button = Button("Cancel", on_press=self.close)
|
||||
|
||||
contents = list(self.generate_list_items())
|
||||
@ -64,8 +86,8 @@ class StatusComposer(urwid.Frame):
|
||||
|
||||
def generate_list_items(self):
|
||||
if self.in_reply_to:
|
||||
yield urwid.Text(("gray", "Replying to {}".format(self.in_reply_to.original.account)))
|
||||
yield urwid.AttrWrap(urwid.Divider("-"), "gray")
|
||||
yield urwid.Text(("dim", "Replying to {}".format(self.in_reply_to.original.account)))
|
||||
yield urwid.AttrWrap(urwid.Divider("-"), "dim")
|
||||
|
||||
yield urwid.Text("Status message")
|
||||
yield self.content_edit
|
||||
|
@ -1,8 +1,21 @@
|
||||
# name, fg, bg, mono, fg_h, bg_h
|
||||
# Color definitions are tuples of:
|
||||
# - name
|
||||
# - foreground (normal mode)
|
||||
# - background (normal mode)
|
||||
# - foreground (monochrome mode)
|
||||
# - foreground (high color mode)
|
||||
# - background (high color mode)
|
||||
#
|
||||
# See:
|
||||
# http://urwid.org/tutorial/index.html#display-attributes
|
||||
# http://urwid.org/manual/displayattributes.html#using-display-attributes
|
||||
|
||||
PALETTE = [
|
||||
# Components
|
||||
('button', 'white', 'black'),
|
||||
('button_focused', 'light gray', 'dark magenta'),
|
||||
('button_focused', 'light gray', 'dark magenta', 'bold,underline'),
|
||||
('card_author', 'yellow', ''),
|
||||
('card_title', 'dark green', ''),
|
||||
('columns_divider', 'white', 'dark blue'),
|
||||
('content_warning', 'white', 'dark magenta'),
|
||||
('editbox', 'white', 'black'),
|
||||
@ -12,32 +25,61 @@ PALETTE = [
|
||||
('footer_status', 'white', 'dark blue'),
|
||||
('footer_status_bold', 'white, bold', 'dark blue'),
|
||||
('header', 'white', 'dark blue'),
|
||||
('header_bold', 'white,bold', 'dark blue'),
|
||||
('header_bold', 'white,bold', 'dark blue', 'bold'),
|
||||
('intro_bigtext', 'yellow', ''),
|
||||
('intro_smalltext', 'light blue', ''),
|
||||
('poll_bar', 'white', 'dark blue'),
|
||||
('status_detail_account', 'dark green', ''),
|
||||
('status_detail_bookmarked', 'light red', ''),
|
||||
('status_detail_timestamp', 'light blue', ''),
|
||||
('status_list_account', 'dark green', ''),
|
||||
('status_list_selected', 'white,bold', 'dark green', 'bold,underline'),
|
||||
('status_list_timestamp', 'light blue', ''),
|
||||
|
||||
# Functional
|
||||
('hashtag', 'light cyan,bold', ''),
|
||||
('followed_hashtag', 'yellow,bold', ''),
|
||||
('link', ',italics', ''),
|
||||
('link_focused', ',italics', 'dark magenta'),
|
||||
|
||||
# Colors
|
||||
('bold', ',bold', ''),
|
||||
('blue', 'light blue', ''),
|
||||
('blue_bold', 'light blue, bold', ''),
|
||||
('blue_selected', 'white', 'dark blue'),
|
||||
('cyan', 'dark cyan', ''),
|
||||
('cyan_bold', 'dark cyan,bold', ''),
|
||||
('gray', 'dark gray', ''),
|
||||
('green', 'dark green', ''),
|
||||
('green_selected', 'white,bold', 'dark green'),
|
||||
('yellow', 'yellow', ''),
|
||||
('yellow_bold', 'yellow,bold', ''),
|
||||
('red', 'dark red', ''),
|
||||
('account', 'dark green', ''),
|
||||
('hashtag', 'light cyan,bold', '', 'bold'),
|
||||
('hashtag_followed', 'yellow,bold', '', 'bold'),
|
||||
('link', ',italics', '', ',italics'),
|
||||
('link_focused', ',italics', 'dark magenta', "underline,italics"),
|
||||
('shortcut', 'light blue', ''),
|
||||
('shortcut_highlight', 'white,bold', '', 'bold'),
|
||||
('warning', 'light red', ''),
|
||||
('white_bold', 'white,bold', '')
|
||||
|
||||
# Visibility
|
||||
('visibility_public', 'dark gray', ''),
|
||||
('visibility_unlisted', 'white', ''),
|
||||
('visibility_private', 'dark cyan', ''),
|
||||
('visibility_direct', 'yellow', ''),
|
||||
|
||||
# Styles
|
||||
('bold', ',bold', ''),
|
||||
('dim', 'dark gray', ''),
|
||||
('highlight', 'yellow', ''),
|
||||
('success', 'dark green', ''),
|
||||
|
||||
# HTML tag styling
|
||||
('a', ',italics', '', 'italics'),
|
||||
# em tag is mapped to i
|
||||
('i', ',italics', '', 'italics'),
|
||||
# strong tag is mapped to b
|
||||
('b', ',bold', '', 'bold'),
|
||||
# special case for bold + italic nested tags
|
||||
('bi', ',bold,italics', '', ',bold,italics'),
|
||||
('u', ',underline', '', ',underline'),
|
||||
('del', ',strikethrough', '', ',strikethrough'),
|
||||
('code', 'light gray, standout', '', ',standout'),
|
||||
('pre', 'light gray, standout', '', ',standout'),
|
||||
('blockquote', 'light gray', '', ''),
|
||||
('h1', ',bold', '', ',bold'),
|
||||
('h2', ',bold', '', ',bold'),
|
||||
('h3', ',bold', '', ',bold'),
|
||||
('h4', ',bold', '', ',bold'),
|
||||
('h5', ',bold', '', ',bold'),
|
||||
('h6', ',bold', '', ',bold'),
|
||||
('class_mention_hashtag', 'light cyan', '', ''),
|
||||
('class_hashtag', 'light cyan', '', ''),
|
||||
|
||||
]
|
||||
|
||||
VISIBILITY_OPTIONS = [
|
||||
|
@ -1,6 +1,6 @@
|
||||
from collections import namedtuple
|
||||
|
||||
from .utils import parse_datetime
|
||||
from toot.utils.datetime import parse_datetime
|
||||
|
||||
Author = namedtuple("Author", ["account", "display_name", "username"])
|
||||
|
||||
@ -53,6 +53,10 @@ class Status:
|
||||
self.id = self.data["id"]
|
||||
self.account = self._get_account()
|
||||
self.created_at = parse_datetime(data["created_at"])
|
||||
if data["edited_at"]:
|
||||
self.edited_at = parse_datetime(data["edited_at"])
|
||||
else:
|
||||
self.edited_at = None
|
||||
self.author = self._get_author()
|
||||
self.favourited = data.get("favourited", False)
|
||||
self.reblogged = data.get("reblogged", False)
|
||||
|
@ -4,20 +4,39 @@ import urwid
|
||||
import webbrowser
|
||||
|
||||
from toot import __version__
|
||||
|
||||
from .utils import highlight_keys
|
||||
from .widgets import Button, EditBox, SelectableText
|
||||
from toot import api
|
||||
from toot.tui.utils import highlight_keys
|
||||
from toot.tui.widgets import Button, EditBox, SelectableText
|
||||
from toot.tui.richtext import html_to_widgets
|
||||
|
||||
|
||||
class StatusSource(urwid.ListBox):
|
||||
class StatusSource(urwid.Padding):
|
||||
"""Shows status data, as returned by the server, as formatted JSON."""
|
||||
def __init__(self, status):
|
||||
source = json.dumps(status.data, indent=4)
|
||||
lines = source.splitlines()
|
||||
self.source = json.dumps(status.data, indent=4)
|
||||
self.filename_edit = EditBox(caption="Filename: ", edit_text=f"status-{status.id}.json")
|
||||
self.status_text = urwid.Text("")
|
||||
|
||||
walker = urwid.SimpleFocusListWalker([
|
||||
urwid.Text(line) for line in lines
|
||||
self.filename_edit,
|
||||
Button("Save", on_press=self.save_json),
|
||||
urwid.Divider("─"),
|
||||
urwid.Divider(" "),
|
||||
urwid.Text(self.source)
|
||||
])
|
||||
super().__init__(walker)
|
||||
|
||||
frame = urwid.Frame(
|
||||
body=urwid.ListBox(walker),
|
||||
footer=self.status_text
|
||||
)
|
||||
super().__init__(frame)
|
||||
|
||||
def save_json(self, button):
|
||||
filename = self.filename_edit.get_edit_text()
|
||||
if filename:
|
||||
with open(filename, "w") as f:
|
||||
f.write(self.source)
|
||||
self.status_text.set_text(("footer_message", f"Saved to {filename}"))
|
||||
|
||||
|
||||
class StatusZoom(urwid.ListBox):
|
||||
@ -62,15 +81,15 @@ class StatusDeleteConfirmation(urwid.ListBox):
|
||||
signals = ["delete", "close"]
|
||||
|
||||
def __init__(self, status):
|
||||
yes = SelectableText("Yes, send it to heck")
|
||||
no = SelectableText("No, I'll spare it for now")
|
||||
def _delete(_):
|
||||
self._emit("delete")
|
||||
|
||||
urwid.connect_signal(yes, "click", lambda *args: self._emit("delete"))
|
||||
urwid.connect_signal(no, "click", lambda *args: self._emit("close"))
|
||||
def _close(_):
|
||||
self._emit("close")
|
||||
|
||||
walker = urwid.SimpleFocusListWalker([
|
||||
urwid.AttrWrap(yes, "", "blue_selected"),
|
||||
urwid.AttrWrap(no, "", "blue_selected"),
|
||||
Button("Yes, delete", on_press=_delete),
|
||||
Button("No, cancel", on_press=_close),
|
||||
])
|
||||
super().__init__(walker)
|
||||
|
||||
@ -81,19 +100,24 @@ class GotoMenu(urwid.ListBox):
|
||||
"public_timeline",
|
||||
"hashtag_timeline",
|
||||
"bookmark_timeline",
|
||||
"notification_timeline",
|
||||
"conversation_timeline",
|
||||
"personal_timeline",
|
||||
"list_timeline",
|
||||
]
|
||||
|
||||
def __init__(self, user_timelines):
|
||||
def __init__(self, user_timelines, user_lists):
|
||||
self.hash_edit = EditBox(caption="Hashtag: ")
|
||||
self.message_widget = urwid.Text("")
|
||||
|
||||
actions = list(self.generate_actions(user_timelines))
|
||||
actions = list(self.generate_actions(user_timelines, user_lists))
|
||||
walker = urwid.SimpleFocusListWalker(actions)
|
||||
super().__init__(walker)
|
||||
|
||||
def get_hashtag(self):
|
||||
return self.hash_edit.edit_text.strip()
|
||||
return self.hash_edit.edit_text.strip().lstrip("#")
|
||||
|
||||
def generate_actions(self, user_timelines):
|
||||
def generate_actions(self, user_timelines, user_lists):
|
||||
def _home(button):
|
||||
self._emit("home_timeline")
|
||||
|
||||
@ -103,35 +127,64 @@ class GotoMenu(urwid.ListBox):
|
||||
def _global_public(button):
|
||||
self._emit("public_timeline", False)
|
||||
|
||||
def _personal(button):
|
||||
self._emit("personal_timeline", False)
|
||||
|
||||
def _bookmarks(button):
|
||||
self._emit("bookmark_timeline", False)
|
||||
|
||||
def _notifications(button):
|
||||
self._emit("notification_timeline", False)
|
||||
|
||||
def _conversations(button):
|
||||
self._emit("conversation_timeline", False)
|
||||
|
||||
def _hashtag(local):
|
||||
self.message_widget.set_text("")
|
||||
hashtag = self.get_hashtag()
|
||||
if hashtag:
|
||||
self._emit("hashtag_timeline", hashtag, local)
|
||||
else:
|
||||
self.set_focus(4)
|
||||
self.message_widget.set_text(("warning", "Hashtag name required"))
|
||||
|
||||
def mk_on_press_user_hashtag(tag, local):
|
||||
def on_press(btn):
|
||||
self._emit("hashtag_timeline", tag, local)
|
||||
return on_press
|
||||
|
||||
def mk_on_press_user_list(list_item):
|
||||
def on_press(btn):
|
||||
self._emit("list_timeline", list_item)
|
||||
return on_press
|
||||
|
||||
yield Button("Home timeline", on_press=_home)
|
||||
|
||||
for tag, cfg in user_timelines.items():
|
||||
is_local = cfg["local"]
|
||||
yield Button("#{}".format(tag) + (" (local)" if is_local else ""),
|
||||
on_press=mk_on_press_user_hashtag(tag, is_local))
|
||||
|
||||
yield Button("Local public timeline", on_press=_local_public)
|
||||
yield Button("Global public timeline", on_press=_global_public)
|
||||
yield Button("Personal timeline", on_press=_personal)
|
||||
yield Button("Bookmarks", on_press=_bookmarks)
|
||||
yield Button("Notifications", on_press=_notifications)
|
||||
yield Button("Conversations", on_press=_conversations)
|
||||
|
||||
if len(user_timelines):
|
||||
yield urwid.Divider()
|
||||
yield urwid.Text(("bold", "Shortcuts:"))
|
||||
|
||||
# show all hashtag shortcuts
|
||||
for tag, cfg in sorted(user_timelines.items()):
|
||||
is_local = cfg["local"]
|
||||
yield Button(f"#{tag}" + (" (local)" if is_local else ""),
|
||||
on_press=mk_on_press_user_hashtag(tag, is_local))
|
||||
|
||||
for list_item in user_lists:
|
||||
yield Button(f"\N{clipboard}{list_item['title']}",
|
||||
on_press=mk_on_press_user_list(list_item))
|
||||
|
||||
yield urwid.Divider()
|
||||
yield self.hash_edit
|
||||
yield Button("Local hashtag timeline", on_press=lambda x: _hashtag(True))
|
||||
yield Button("Public hashtag timeline", on_press=lambda x: _hashtag(False))
|
||||
yield urwid.Divider()
|
||||
yield self.message_widget
|
||||
|
||||
|
||||
class Help(urwid.Padding):
|
||||
@ -143,15 +196,9 @@ class Help(urwid.Padding):
|
||||
|
||||
def generate_contents(self):
|
||||
def h(text):
|
||||
return highlight_keys(text, "cyan")
|
||||
return highlight_keys(text, "shortcut")
|
||||
|
||||
def link(text, url):
|
||||
attr_map = {"link": "link_focused"}
|
||||
text = SelectableText([text, ("link", url)])
|
||||
urwid.connect_signal(text, "click", lambda t: webbrowser.open(url))
|
||||
return urwid.AttrMap(text, "", attr_map)
|
||||
|
||||
yield urwid.Text(("yellow_bold", "toot {}".format(__version__)))
|
||||
yield urwid.Text(("bold", "toot {}".format(__version__)))
|
||||
yield urwid.Divider()
|
||||
yield urwid.Text(("bold", "General usage"))
|
||||
yield urwid.Divider()
|
||||
@ -164,9 +211,9 @@ class Help(urwid.Padding):
|
||||
yield urwid.Divider()
|
||||
yield urwid.Text(h(" [Q] - quit toot"))
|
||||
yield urwid.Text(h(" [G] - go to - switch timelines"))
|
||||
yield urwid.Text(h(" [P] - save/unsave (pin) current timeline"))
|
||||
yield urwid.Text(h(" [E] - save/unsave (pin) current timeline"))
|
||||
yield urwid.Text(h(" [,] - refresh current timeline"))
|
||||
yield urwid.Text(h(" [H] - show this help"))
|
||||
yield urwid.Text(h(" [?] - show this help"))
|
||||
yield urwid.Divider()
|
||||
yield urwid.Text(("bold", "Status keys"))
|
||||
yield urwid.Divider()
|
||||
@ -179,13 +226,139 @@ class Help(urwid.Padding):
|
||||
yield urwid.Text(h(" [N] - Translate status if possible (toggle)"))
|
||||
yield urwid.Text(h(" [R] - Reply to current status"))
|
||||
yield urwid.Text(h(" [S] - Show text marked as sensitive"))
|
||||
yield urwid.Text(h(" [M] - Show status media"))
|
||||
yield urwid.Text(h(" [T] - Show status thread (replies)"))
|
||||
yield urwid.Text(h(" [L] - Show the status links"))
|
||||
yield urwid.Text(h(" [U] - Show the status data in JSON as received from the server"))
|
||||
yield urwid.Text(h(" [V] - Open status in default browser"))
|
||||
yield urwid.Text(h(" [Y] - Copy status to clipboard"))
|
||||
yield urwid.Text(h(" [Z] - Open status in scrollable popup window"))
|
||||
yield urwid.Divider()
|
||||
yield urwid.Text(("bold", "Links"))
|
||||
yield urwid.Divider()
|
||||
yield link("Documentation: ", "https://toot.readthedocs.io/")
|
||||
yield link("Documentation: ", "https://toot.bezdomni.net/")
|
||||
yield link("Project home: ", "https://github.com/ihabunek/toot/")
|
||||
|
||||
|
||||
class Account(urwid.ListBox):
|
||||
"""Shows account data and provides various actions"""
|
||||
def __init__(self, app, user, account, relationship):
|
||||
self.app = app
|
||||
self.user = user
|
||||
self.account = account
|
||||
self.relationship = relationship
|
||||
self.last_action = None
|
||||
self.setup_listbox()
|
||||
|
||||
def setup_listbox(self):
|
||||
actions = list(self.generate_contents(self.account, self.relationship, self.last_action))
|
||||
walker = urwid.SimpleListWalker(actions)
|
||||
super().__init__(walker)
|
||||
|
||||
def generate_contents(self, account, relationship=None, last_action=None):
|
||||
if self.last_action and not self.last_action.startswith("Confirm"):
|
||||
yield Button(f"Confirm {self.last_action}", on_press=take_action, user_data=self)
|
||||
yield Button("Cancel", on_press=cancel_action, user_data=self)
|
||||
else:
|
||||
if self.user.username == account["acct"]:
|
||||
yield urwid.Text(("dim", "This is your account"))
|
||||
else:
|
||||
if relationship['requested']:
|
||||
yield urwid.Text(("dim", "< Follow request is pending >"))
|
||||
else:
|
||||
yield Button("Unfollow" if relationship['following'] else "Follow",
|
||||
on_press=confirm_action, user_data=self)
|
||||
|
||||
yield Button("Unmute" if relationship['muting'] else "Mute",
|
||||
on_press=confirm_action, user_data=self)
|
||||
yield Button("Unblock" if relationship['blocking'] else "Block",
|
||||
on_press=confirm_action, user_data=self)
|
||||
|
||||
yield urwid.Divider("─")
|
||||
yield urwid.Divider()
|
||||
yield urwid.Text([("account", f"@{account['acct']}"), f" {account['display_name']}"])
|
||||
|
||||
if account["note"]:
|
||||
yield urwid.Divider()
|
||||
|
||||
widgetlist = html_to_widgets(account["note"])
|
||||
for line in widgetlist:
|
||||
yield (line)
|
||||
|
||||
yield urwid.Divider()
|
||||
yield urwid.Text(["ID: ", ("highlight", f"{account['id']}")])
|
||||
yield urwid.Text(["Since: ", ("highlight", f"{account['created_at'][:10]}")])
|
||||
yield urwid.Divider()
|
||||
|
||||
if account["bot"]:
|
||||
yield urwid.Text([("highlight", "Bot \N{robot face}")])
|
||||
yield urwid.Divider()
|
||||
if account["locked"]:
|
||||
yield urwid.Text([("warning", "Locked \N{lock}")])
|
||||
yield urwid.Divider()
|
||||
if "suspended" in account and account["suspended"]:
|
||||
yield urwid.Text([("warning", "Suspended \N{cross mark}")])
|
||||
yield urwid.Divider()
|
||||
if relationship["followed_by"]:
|
||||
yield urwid.Text(("highlight", "Follows you \N{busts in silhouette}"))
|
||||
yield urwid.Divider()
|
||||
if relationship["blocked_by"]:
|
||||
yield urwid.Text(("warning", "Blocks you \N{no entry}"))
|
||||
yield urwid.Divider()
|
||||
|
||||
yield urwid.Text(["Followers: ", ("highlight", f"{account['followers_count']}")])
|
||||
yield urwid.Text(["Following: ", ("highlight", f"{account['following_count']}")])
|
||||
yield urwid.Text(["Statuses: ", ("highlight", f"{account['statuses_count']}")])
|
||||
|
||||
if account["fields"]:
|
||||
for field in account["fields"]:
|
||||
name = field["name"].title()
|
||||
yield urwid.Divider()
|
||||
yield urwid.Text([("bold", f"{name.rstrip(':')}"), ":"])
|
||||
|
||||
widgetlist = html_to_widgets(field["value"])
|
||||
for line in widgetlist:
|
||||
yield (line)
|
||||
|
||||
if field["verified_at"]:
|
||||
yield urwid.Text(("success", "✓ Verified"))
|
||||
|
||||
yield urwid.Divider()
|
||||
yield link("", account["url"])
|
||||
|
||||
|
||||
def take_action(button: Button, self: Account):
|
||||
action = button.get_label()
|
||||
|
||||
if action == "Confirm Follow":
|
||||
self.relationship = api.follow(self.app, self.user, self.account["id"]).json()
|
||||
elif action == "Confirm Unfollow":
|
||||
self.relationship = api.unfollow(self.app, self.user, self.account["id"]).json()
|
||||
elif action == "Confirm Mute":
|
||||
self.relationship = api.mute(self.app, self.user, self.account["id"]).json()
|
||||
elif action == "Confirm Unmute":
|
||||
self.relationship = api.unmute(self.app, self.user, self.account["id"]).json()
|
||||
elif action == "Confirm Block":
|
||||
self.relationship = api.block(self.app, self.user, self.account["id"]).json()
|
||||
elif action == "Confirm Unblock":
|
||||
self.relationship = api.unblock(self.app, self.user, self.account["id"]).json()
|
||||
|
||||
self.last_action = None
|
||||
self.setup_listbox()
|
||||
|
||||
|
||||
def confirm_action(button: Button, self: Account):
|
||||
self.last_action = button.get_label()
|
||||
self.setup_listbox()
|
||||
|
||||
|
||||
def cancel_action(button: Button, self: Account):
|
||||
self.last_action = None
|
||||
self.setup_listbox()
|
||||
|
||||
|
||||
def link(text, url):
|
||||
attr_map = {"link": "link_focused"}
|
||||
text = SelectableText([text, ("link", url)])
|
||||
urwid.connect_signal(text, "click", lambda t: webbrowser.open(url))
|
||||
return urwid.AttrMap(text, "", attr_map)
|
||||
|
105
toot/tui/poll.py
Normal file
@ -0,0 +1,105 @@
|
||||
import urwid
|
||||
|
||||
from toot import api
|
||||
from toot.exceptions import ApiError
|
||||
from toot.utils.datetime import parse_datetime
|
||||
from .widgets import Button, CheckBox, RadioButton, RoundedLineBox
|
||||
from .richtext import html_to_widgets
|
||||
|
||||
|
||||
class Poll(urwid.ListBox):
|
||||
"""View and vote on a poll"""
|
||||
|
||||
def __init__(self, app, user, status):
|
||||
self.status = status
|
||||
self.app = app
|
||||
self.user = user
|
||||
self.poll = status.original.data.get("poll")
|
||||
self.button_group = []
|
||||
self.api_exception = None
|
||||
self.setup_listbox()
|
||||
|
||||
def setup_listbox(self):
|
||||
actions = list(self.generate_contents(self.status))
|
||||
walker = urwid.SimpleListWalker(actions)
|
||||
super().__init__(walker)
|
||||
|
||||
def build_linebox(self, contents):
|
||||
contents = urwid.Pile(list(contents))
|
||||
contents = urwid.Padding(contents, left=1, right=1)
|
||||
return RoundedLineBox(contents)
|
||||
|
||||
def vote(self, button_widget):
|
||||
poll = self.status.original.data.get("poll")
|
||||
choices = []
|
||||
for idx, button in enumerate(self.button_group):
|
||||
if button.get_state():
|
||||
choices.append(idx)
|
||||
|
||||
if len(choices):
|
||||
try:
|
||||
response = api.vote(self.app, self.user, poll["id"], choices=choices)
|
||||
self.status.original.data["poll"] = response
|
||||
self.api_exception = None
|
||||
self.poll["voted"] = True
|
||||
self.poll["own_votes"] = choices
|
||||
except ApiError as exception:
|
||||
self.api_exception = exception
|
||||
finally:
|
||||
self.setup_listbox()
|
||||
|
||||
def generate_poll_detail(self):
|
||||
poll = self.poll
|
||||
|
||||
self.button_group = [] # button group
|
||||
for idx, option in enumerate(poll["options"]):
|
||||
voted_for = (
|
||||
poll["voted"] and poll["own_votes"] and idx in poll["own_votes"]
|
||||
)
|
||||
|
||||
if poll["voted"] or poll["expired"]:
|
||||
prefix = " ✓ " if voted_for else " "
|
||||
yield urwid.Text(("dim", prefix + f'{option["title"]}'))
|
||||
else:
|
||||
if poll["multiple"]:
|
||||
checkbox = CheckBox(f'{option["title"]}')
|
||||
self.button_group.append(checkbox)
|
||||
yield checkbox
|
||||
else:
|
||||
yield RadioButton(self.button_group, f'{option["title"]}')
|
||||
|
||||
yield urwid.Divider()
|
||||
|
||||
poll_detail = "Poll · {} votes".format(poll["votes_count"])
|
||||
|
||||
if poll["expired"]:
|
||||
poll_detail += " · Closed"
|
||||
|
||||
if poll["expires_at"]:
|
||||
expires_at = parse_datetime(poll["expires_at"]).strftime(
|
||||
"%Y-%m-%d %H:%M"
|
||||
)
|
||||
poll_detail += " · Closes on {}".format(expires_at)
|
||||
|
||||
yield urwid.Text(("dim", poll_detail))
|
||||
|
||||
def generate_contents(self, status):
|
||||
yield urwid.Divider()
|
||||
|
||||
widgetlist = html_to_widgets(status.data["content"])
|
||||
|
||||
for line in widgetlist:
|
||||
yield (line)
|
||||
|
||||
yield urwid.Divider()
|
||||
yield self.build_linebox(self.generate_poll_detail())
|
||||
yield urwid.Divider()
|
||||
|
||||
if self.poll["voted"]:
|
||||
yield urwid.Text(("grey", "< Already Voted >"))
|
||||
elif not self.poll["expired"]:
|
||||
yield Button("Vote", on_press=self.vote)
|
||||
|
||||
if self.api_exception:
|
||||
yield urwid.Divider()
|
||||
yield urwid.Text("warning", str(self.api_exception))
|
18
toot/tui/richtext/__init__.py
Normal file
@ -0,0 +1,18 @@
|
||||
import urwid
|
||||
|
||||
from toot.tui.utils import highlight_hashtags
|
||||
from toot.utils import format_content
|
||||
from typing import List
|
||||
|
||||
try:
|
||||
from .richtext import html_to_widgets, url_to_widget
|
||||
except ImportError:
|
||||
# Fallback if urwidgets are not available
|
||||
def html_to_widgets(html: str) -> List[urwid.Widget]:
|
||||
return [
|
||||
urwid.Text(highlight_hashtags(line))
|
||||
for line in format_content(html)
|
||||
]
|
||||
|
||||
def url_to_widget(url: str):
|
||||
return urwid.Text(("link", url))
|