Merge branch 'master' into asyncfix

This commit is contained in:
Daniel Schwarz 2024-03-05 20:03:05 -05:00
commit 9d59df6c7e
109 changed files with 9452 additions and 4082 deletions

View File

@ -1,4 +1,4 @@
[flake8]
exclude=build,tests,tmp,venv,toot/tui/scroll.py
ignore=E128
ignore=E128,W503,W504
max-line-length=120

View File

@ -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
View File

@ -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

View File

@ -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
View File

@ -0,0 +1,4 @@
[vermin]
only_show_violations = yes
show_tips = no
targets = 3.7

View File

@ -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)**

View File

@ -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

View File

@ -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"

View File

@ -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
View 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
View 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"]

View File

@ -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

View File

@ -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
View 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)

View File

@ -1,10 +0,0 @@
pre {
padding: 8px 15px;
}
div.contents {
background-color: inherit;
border: 0;
margin-top: 0;
padding-top: 0;
}

View File

@ -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 %}

View File

@ -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
View 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

View File

@ -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
View 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
View 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
```

View File

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

View File

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

View File

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 192 KiB

View File

Before

Width:  |  Height:  |  Size: 209 KiB

After

Width:  |  Height:  |  Size: 209 KiB

View File

@ -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>`_.

View File

@ -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
View 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
View 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
View 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>.

View File

@ -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
View 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
```

View File

@ -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
View 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
View File

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

BIN
docs/trumpet.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

47
docs/tui.md Normal file
View 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
View 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
```

View File

@ -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
View File

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

View File

@ -1,8 +0,0 @@
coverage
keyring
pyxdg
pyyaml
sphinx
sphinx-autobuild
twine
wheel

View File

@ -1,5 +0,0 @@
flake8
psycopg2-binary
pytest
pytest-xdist[psutil]
vermin

View File

@ -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

View File

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

View File

@ -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

View File

@ -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

Binary file not shown.

View File

View 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

View 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 == []

View 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")

View 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 == []

View 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

View 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"]

View 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

View File

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

View File

@ -0,0 +1,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())

View File

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

View File

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

View File

@ -1,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
#

View File

@ -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'])

View File

@ -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&apos;t tell you the emotional story. It can give you the exact mathematical design, but what's missing is the eyebrows.</p>",
'reblog': None,
'in_reply_to_id': None,
'media_attachments': [],
}])
console.run_command(app, user, 'timeline', ['--once'])
mock_get.assert_called_once_with(app, user, '/api/v1/timelines/home?limit=10', 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&apos;t tell you the emotional story. It can give you the exact mathematical design, but what's missing is the eyebrows.</p>",
'media_attachments': [],
},
'in_reply_to_id': '111111111111111110',
'media_attachments': [],
}])
console.run_command(app, user, 'timeline', ['--once'])
mock_get.assert_called_once_with(app, user, '/api/v1/timelines/home?limit=10', 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

View File

@ -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

View File

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

View File

@ -1,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"

View 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"

View File

@ -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()

View File

@ -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
View File

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

View File

@ -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)

View File

@ -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
View 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
View File

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

143
toot/cli/auth.py Normal file
View 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
View 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
View 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
View 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
View File

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

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

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

184
toot/cli/timelines.py Normal file
View 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
View 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
View File

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

View File

@ -1,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()

View File

@ -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

View File

@ -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
View 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

View File

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

View File

@ -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)

View File

@ -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

View File

@ -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
View 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

View File

@ -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:

View File

@ -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

View File

@ -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 = [

View File

@ -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)

View File

@ -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
View 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))

View 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))

Some files were not shown because too many files have changed in this diff Show More