diff --git a/.flake8 b/.flake8 index 21fd7bd..ac93b71 100644 --- a/.flake8 +++ b/.flake8 @@ -1,4 +1,4 @@ [flake8] exclude=build,tests,tmp,venv,toot/tui/scroll.py -ignore=E128 +ignore=E128,W503,W504 max-line-length=120 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e93c9bc..5417a2f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/.gitignore b/.gitignore index 3d6775f..957a67b 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 5dff351..0000000 --- a/.travis.yml +++ /dev/null @@ -1,13 +0,0 @@ -language: python - -python: - - "3.4" - - "3.5" - - "3.6" - - "3.7" - - "nightly" - -install: - - pip install -e . - -script: make test diff --git a/.vermin b/.vermin new file mode 100644 index 0000000..7668f86 --- /dev/null +++ b/.vermin @@ -0,0 +1,4 @@ +[vermin] +only_show_violations = yes +show_tips = no +targets = 3.7 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b4c601..eba2a47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,129 @@ Changelog +**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)** diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0d58901..37a81e4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/Makefile b/Makefile index c1aaa5f..dfd83c4 100644 --- a/Makefile +++ b/Makefile @@ -10,17 +10,40 @@ publish : test: pytest -v flake8 - vermin --target=3.6 --no-tips --violations --exclude-regex venv/.* . + vermin toot coverage: coverage erase coverage run - coverage html + coverage html --omit "toot/tui/*" coverage report clean : find . -name "*pyc" | xargs rm -rf $1 - rm -rf build dist MANIFEST htmlcov toot*.tar.gz + rm -rf build dist MANIFEST htmlcov bundle toot*.tar.gz toot*.pyz changelog: ./scripts/generate_changelog > CHANGELOG.md + cp CHANGELOG.md docs/changelog.md + +docs: changelog + mdbook build + +docs-serve: + mdbook serve --port 8000 + +docs-deploy: docs + rsync --archive --compress --delete --stats book/ bezdomni:web/toot + +.PHONY: bundle +bundle: + mkdir bundle + cp toot/__main__.py bundle + pip install . --target=bundle + rm -rf bundle/*.dist-info + find bundle/ -type d -name "__pycache__" -exec rm -rf {} + + python -m zipapp \ + --python "/usr/bin/env python3" \ + --output toot-`git describe`.pyz bundle \ + --compress + echo "Bundle created: toot-`git describe`.pyz" diff --git a/README.rst b/README.rst index 552d43d..be20e19 100644 --- a/README.rst +++ b/README.rst @@ -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 `_ @@ -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 diff --git a/book.css b/book.css new file mode 100644 index 0000000..62767b2 --- /dev/null +++ b/book.css @@ -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; +} diff --git a/book.toml b/book.toml new file mode 100644 index 0000000..78e03de --- /dev/null +++ b/book.toml @@ -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"] diff --git a/changelog.yaml b/changelog.yaml index fe79326..5b12891 100644 --- a/changelog.yaml +++ b/changelog.yaml @@ -1,3 +1,122 @@ +0.41.1: + date: 2024-01-02 + changes: + - "Fix a crash in settings parsing code" + +0.41.0: + date: 2024-01-02 + changes: + - "Honour user's default visibility set in Mastodon preferences instead of always defaulting to public visibility (thanks Lexi Winter)" + - "TUI: Add editing toots (thanks Lexi Winter)" + - "TUI: Fix a bug which made palette config in settings not work" + - "TUI: Show edit datetime in status detail (thanks Lexi Winter)" + +0.40.2: + date: 2023-12-28 + changes: + - "Reinstate `toot post --using` option." + - "Add shell completion for instances." + +0.40.1: + date: 2023-12-28 + changes: + - "Add `toot --as` option to replace `toot post --using`. This now works for all commands." + +0.40.0: + date: 2023-12-27 + description: | + This release includes a rather extensive change to use the Click library + (https://click.palletsprojects.com/) for creating the command line + interface. This allows for some new features like nested commands, setting + parameters via environment variables, and shell completion. Backward + compatibility should be mostly preserved, except for cases noted below. + Please report any issues. + changes: + - "BREAKING: Remove deprecated `--disable-https` option for `login` and `login_cli`, pass the base URL instead" + - "BREAKING: Options `--debug` and `--color` must be specified after `toot` but before the command" + - "BREAKING: Option `--quiet` has been removed. Redirect output instead." + - "Add passing parameters via environment variables, see: https://toot.bezdomni.net/environment_variables.html" + - "Add shell completion, see: https://toot.bezdomni.net/shell_completion.html" + - "Add `tags info`, `tags featured`, `tags feature`, and `tags unfeature` commands" + - "Add `tags followed`, `tags follow`, and `tags unfollow` sub-commands, deprecate `tags_followed`, `tags_follow`, and `tags tags_unfollow`" + - "Add `lists accounts`, `lists add`, `lists create`, `lists delete`, `lists list`, `lists remove` subcommands, deprecate `lists`, `lists_accounts`, `lists_add`, `lists_create`, `lists_delete`, `lists_remove` commands." + - "Add `--json` option to tags and lists commands" + - "Add `toot --width` option for setting your preferred terminal width" + - "Add `--media-viewer` and `--colors` options to `toot tui`. These were previously accessible only via settings." + - "TUI: Fix issue where UI did not render until first input (thanks Urwid devs)" + +0.39.0: + date: 2023-11-23 + changes: + - "Add `--json` option to many commands, this makes them print the JSON data returned by the server instead of human-readable data. Useful for scripting." + - "TUI: Make media viewer configurable in settings, see: https://toot.bezdomni.net/settings.html#tui-view-images" + - "TUI: Add rich text rendering (thanks Dan Schwarz)" + +0.38.2: + date: 2023-11-16 + changes: + - "Fix compatibility with Pleroma (#399, thanks Sandra Snan)" + - "Fix language documentation (thanks Sandra Snan)" + +0.38.1: + date: 2023-07-25 + changes: + - "Fix relative datetimes option in TUI" + +0.38.0: + date: 2023-07-25 + changes: + - "Add `toot muted` and `toot blocked` commands (thanks Florian Obser)" + - "Add settings file, allows setting common options, defining defaults for command arguments, and the TUI palette" + - "TUI: Remap shortcuts so they don't override HJKL used for navigation (thanks Dan Schwarz)" + +0.37.0: + date: 2023-06-28 + changes: + - "**BREAKING:** Require Python 3.7+" + - "Add `timeline --account` option to show the account timeline (thanks Dan Schwarz)" + - "Add `toot status` command to show a single status" + - "TUI: Add personal timeline (thanks Dan Schwarz)" + - "TUI: Highlight followed accounts in status details (thanks Dan Schwarz)" + - "TUI: Restructured goto menu (thanks Dan Schwarz)" + - "TUI: Fix boosting boosted statuses (thanks Dan Schwarz)" + - "TUI: Add support for list timelines (thanks Dan Schwarz)" + +0.36.0: + date: 2023-03-09 + changes: + - "Move docs from toot.readthedocs.io to toot.bezdomni.net" + - "Add specifying media thumbnails to `toot post` (#301)" + - "Add creating polls to `toot post`" + - "Handle custom instance domains (e.g. when server is located at `social.vivaldi.net`, but uses the `vivaldi.net` mastodon domain. (#217)" + - "TUI: Inherit post visibility when replying (thanks @rogarb)" + - "TUI: Add conversations timeline (thanks @rogarb)" + - "TUI: Add shortcut to copy toot contents (thanks Dan Schwarz)" + +0.35.0: + date: 2023-03-01 + changes: + - "Save toot contents when using --editor so it's recoverable if posting fails (#311)" + - "TUI: Add voting on polls (thanks Dan Schwarz)" + - "TUI: Add following/blocking/muting accounts (thanks Dan Schwarz)" + - "TUI: Add notifications timeline (thanks Dan Schwarz)" + +0.34.1: + date: 2023-02-20 + changes: + - "TUI: Fix bug where TUI would break on older Mastodon instances (#309)" + +0.34.0: + date: 2023-02-03 + changes: + - "Fix Python version detection which would fail in some cases (thanks K)" + - "Fix toot --help not working (thanks Norman Walsh)" + - "TUI: Add option to save status JSON data from source window (thanks Dan Schwarz)" + - "TUI: Add `--relative-datetimes` option to show relative datetimes (thanks Dan Schwarz)" + - "TUI: Don't focus newly created post (#188, thanks Dan Schwarz)" + - "TUI: Add ability to scroll long status messages (#166, thanks Dan Schwarz)" + - "TUI: Add action to view account details (thanks Dan Schwarz)" + 0.33.1: date: 2023-01-03 changes: @@ -14,7 +133,7 @@ - "TUI: Show an error if attemptint to boost a private status (thanks Lim Ding Wen)" - "TUI: Hide polls, cards and media attachments for sensitive posts (thanks Daniel Schwarz)" - "TUI: Add bookmarking and bookmark timeline (thanks Daniel Schwarz)" - - "TUI: Show status visiblity (thanks Lim Ding Wen)" + - "TUI: Show status visibility (thanks Lim Ding Wen)" - "TUI: Reply to original account instead of boosting account (thanks Lim Ding Wen)" - "TUI: Refresh screen after exiting browser, required for text browsers (thanks Daniel Schwarz)" - "TUI: Highlight followed tags (thanks Daniel Schwarz)" @@ -42,7 +161,7 @@ 0.30.1: date: 2022-11-30 changes: - - "Remove usage of depreacted `text_url` status field. Fixes posting media without text." + - "Remove usage of deprecated `text_url` status field. Fixes posting media without text." 0.30.0: date: 2022-11-29 @@ -89,7 +208,7 @@ - "TUI: Fix access to public and tag timelines when on private mastodon instances (#168)" - "Add `--reverse` option to `toot notifications` (#151)" - "Fix `toot timeline` to respect `--instance` option" - - "TUI: Add opton to pin/save tag timelines (#163, thanks @dlax)" + - "TUI: Add option to pin/save tag timelines (#163, thanks @dlax)" - "TUI: Fixed crash on empty timeline (#138, thanks ecs)" 0.26.0: @@ -98,7 +217,7 @@ - "Fix datetime parsing on Python 3.5 (#162)" - "TUI: Display status links and open them (#154, thanks @dlax)" - "TUI: Fix visibility descriptions (#153, thanks @finnoleary)" - - "**IMPORTANT:** Starting from this release, new releases will not be uploaded to the APT package repository at `bezdomni.net`. Please use the official Debian or Ubuntu repos or choose another [installation option](https://toot.readthedocs.io/en/latest/install.html)." + - "**IMPORTANT:** Starting from this release, new releases will not be uploaded to the APT package repository at `bezdomni.net`. Please use the official Debian or Ubuntu repos or choose another [installation option](https://toot.bezdomni.net/installation.html)." 0.25.2: date: 2020-01-23 diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index 57ed83d..0000000 --- a/docs/Makefile +++ /dev/null @@ -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) diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md new file mode 100644 index 0000000..5fe913b --- /dev/null +++ b/docs/SUMMARY.md @@ -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) diff --git a/docs/_static/custom.css b/docs/_static/custom.css deleted file mode 100644 index 9c7a5b3..0000000 --- a/docs/_static/custom.css +++ /dev/null @@ -1,10 +0,0 @@ -pre { - padding: 8px 15px; -} - -div.contents { - background-color: inherit; - border: 0; - margin-top: 0; - padding-top: 0; -} diff --git a/docs/_templates/about.html b/docs/_templates/about.html deleted file mode 100644 index 2119d71..0000000 --- a/docs/_templates/about.html +++ /dev/null @@ -1,5 +0,0 @@ -

{{ project }}

- -{% if theme_description %} -

{{ theme_description }}

-{% endif %} diff --git a/docs/advanced.rst b/docs/advanced.md similarity index 52% rename from docs/advanced.rst rename to docs/advanced.md index 88810cf..6b4c5f2 100644 --- a/docs/advanced.rst +++ b/docs/advanced.md @@ -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 `_ +[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 +``` diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 0000000..eba2a47 --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1,450 @@ +Changelog +--------- + + + +**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 + diff --git a/docs/conf.py b/docs/conf.py deleted file mode 100644 index 174d568..0000000 --- a/docs/conf.py +++ /dev/null @@ -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", - ] -} diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..5fff86a --- /dev/null +++ b/docs/contributing.md @@ -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/) diff --git a/docs/documentation.md b/docs/documentation.md new file mode 100644 index 0000000..62c2282 --- /dev/null +++ b/docs/documentation.md @@ -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 +``` diff --git a/docs/environment_variables.md b/docs/environment_variables.md new file mode 100644 index 0000000..4ba05f7 --- /dev/null +++ b/docs/environment_variables.md @@ -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__`. + +### 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). diff --git a/docs/_static/auth.png b/docs/images/auth.png similarity index 100% rename from docs/_static/auth.png rename to docs/images/auth.png diff --git a/docs/_static/trumpet.png b/docs/images/trumpet.png similarity index 100% rename from docs/_static/trumpet.png rename to docs/images/trumpet.png diff --git a/docs/_static/tui_compose.png b/docs/images/tui_compose.png similarity index 100% rename from docs/_static/tui_compose.png rename to docs/images/tui_compose.png diff --git a/docs/_static/tui_list.png b/docs/images/tui_list.png similarity index 100% rename from docs/_static/tui_list.png rename to docs/images/tui_list.png diff --git a/docs/_static/tui_poll.png b/docs/images/tui_poll.png similarity index 100% rename from docs/_static/tui_poll.png rename to docs/images/tui_poll.png diff --git a/docs/index.rst b/docs/index.rst deleted file mode 100644 index f292069..0000000 --- a/docs/index.rst +++ /dev/null @@ -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 `_ - -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 and contributors. - -Licensed under `GPLv3 `_. diff --git a/docs/install.rst b/docs/install.rst deleted file mode 100644 index af9d1a2..0000000 --- a/docs/install.rst +++ /dev/null @@ -1,123 +0,0 @@ -============ -Installation -============ - -toot is packaged for various platforms. - -.. contents:: - :local: - :backlinks: none - -Overview --------- - -Packaging overview provided by `repology.org `_. - -.. 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 `_. - - -Arch Linux ----------- - -Install from `AUR `_. - -.. 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 `_ - -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 `_ - -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 `_ 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 `_. diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..02ef65e --- /dev/null +++ b/docs/installation.md @@ -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/). diff --git a/docs/introduction.md b/docs/introduction.md new file mode 100644 index 0000000..51f2534 --- /dev/null +++ b/docs/introduction.md @@ -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 and contributors. + +Licensed under the [GPLv3](http://www.gnu.org/licenses/gpl-3.0.html) license. diff --git a/docs/license.md b/docs/license.md new file mode 100644 index 0000000..2fb2e74 --- /dev/null +++ b/docs/license.md @@ -0,0 +1,675 @@ +### GNU GENERAL PUBLIC LICENSE + +Version 3, 29 June 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. + + +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. + + + Copyright (C) + + 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 . + +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: + + Copyright (C) + 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 . + +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 . diff --git a/docs/make.bat b/docs/make.bat deleted file mode 100644 index 5d625a1..0000000 --- a/docs/make.bat +++ /dev/null @@ -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 diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 0000000..6d2dd4d --- /dev/null +++ b/docs/release.md @@ -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 ` 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 +``` diff --git a/docs/release.rst b/docs/release.rst deleted file mode 100644 index d508427..0000000 --- a/docs/release.rst +++ /dev/null @@ -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 `` 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 `_ 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/ diff --git a/docs/settings.md b/docs/settings.md new file mode 100644 index 0000000..c2eeea4 --- /dev/null +++ b/docs/settings.md @@ -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.]` 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"] +``` diff --git a/docs/shell_completion.md b/docs/shell_completion.md new file mode 100644 index 0000000..d4086b8 --- /dev/null +++ b/docs/shell_completion.md @@ -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)" +``` diff --git a/docs/trumpet.png b/docs/trumpet.png new file mode 100644 index 0000000..03233ae Binary files /dev/null and b/docs/trumpet.png differ diff --git a/docs/tui.md b/docs/tui.md new file mode 100644 index 0000000..bbe9f73 --- /dev/null +++ b/docs/tui.md @@ -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 diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..01d2b10 --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,176 @@ +Usage +===== + +Running `toot` displays a list of available commands. + +Running `toot -h` shows the documentation for the given command. + +Below is an overview of some common scenarios. + + + +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 +``` diff --git a/docs/usage.rst b/docs/usage.rst deleted file mode 100644 index 6893408..0000000 --- a/docs/usage.rst +++ /dev/null @@ -1,248 +0,0 @@ -===== -Usage -===== - -Running ``toot`` displays a list of available commands. - -Running ``toot -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 --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 -`_. - -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.* diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..5ee6477 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +testpaths = tests diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index dfa5b15..0000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,8 +0,0 @@ -coverage -keyring -pyxdg -pyyaml -sphinx -sphinx-autobuild -twine -wheel diff --git a/requirements-test.txt b/requirements-test.txt deleted file mode 100644 index 3a35c72..0000000 --- a/requirements-test.txt +++ /dev/null @@ -1,5 +0,0 @@ -flake8 -psycopg2-binary -pytest -pytest-xdist[psutil] -vermin diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 67ddf98..0000000 --- a/requirements.txt +++ /dev/null @@ -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 diff --git a/scripts/generate_changelog b/scripts/generate_changelog index 5e6021e..8aa5d6c 100755 --- a/scripts/generate_changelog +++ b/scripts/generate_changelog @@ -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 diff --git a/scripts/tag_version b/scripts/tag_version index 56efe8d..6f919fe 100755 --- a/scripts/tag_version +++ b/scripts/tag_version @@ -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 diff --git a/setup.py b/setup.py index 079bcc6..7b3b96f 100644 --- a/setup.py +++ b/setup.py @@ -12,14 +12,14 @@ and blocking accounts and other actions. setup( name='toot', - version='0.33.1', + version='0.41.1', description='Mastodon CLI client', long_description=long_description.strip(), author='Ivan Habunek', author_email='ivan@habunek.com', url='https://github.com/ihabunek/toot/', project_urls={ - 'Documentation': 'https://toot.readthedocs.io/en/latest/', + 'Documentation': 'https://toot.bezdomni.net/', 'Issue tracker': 'https://github.com/ihabunek/toot/issues/', }, keywords='mastodon toot', @@ -31,17 +31,40 @@ setup( 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', 'Programming Language :: Python :: 3', ], - packages=['toot', 'toot.tui', 'toot.utils'], - python_requires=">=3.6", + packages=['toot', 'toot.cli', 'toot.tui', 'toot.tui.richtext', 'toot.utils'], + python_requires=">=3.7", install_requires=[ + "click~=8.1", "requests>=2.13,<3.0", "beautifulsoup4>=4.5.0,<5.0", "wcwidth>=0.1.7", "urwid>=2.0.0,<3.0", + "tomlkit>=0.10.0,<1.0" ], + extras_require={ + # Required to display rich text in the TUI + "richtext": [ + "urwidgets>=0.1,<0.2" + ], + "dev": [ + "coverage", + "pyyaml", + "twine", + "wheel", + ], + "test": [ + "flake8", + "psycopg2-binary", + "pytest", + "pytest-xdist[psutil]", + "setuptools", + "vermin", + "typing-extensions", + ], + }, entry_points={ 'console_scripts': [ - 'toot=toot.console:main', + 'toot=toot.cli:cli', ], } ) diff --git a/tests/assets/small.webm b/tests/assets/small.webm new file mode 100644 index 0000000..da946da Binary files /dev/null and b/tests/assets/small.webm differ diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..fbb04f5 --- /dev/null +++ b/tests/integration/conftest.py @@ -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 diff --git a/tests/integration/test_accounts.py b/tests/integration/test_accounts.py new file mode 100644 index 0000000..3df9c92 --- /dev/null +++ b/tests/integration/test_accounts.py @@ -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 == [] diff --git a/tests/integration/test_auth.py b/tests/integration/test_auth.py new file mode 100644 index 0000000..74db83b --- /dev/null +++ b/tests/integration/test_auth.py @@ -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") diff --git a/tests/integration/test_lists.py b/tests/integration/test_lists.py new file mode 100644 index 0000000..979724f --- /dev/null +++ b/tests/integration/test_lists.py @@ -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 == [] diff --git a/tests/integration/test_post.py b/tests/integration/test_post.py new file mode 100644 index 0000000..13226b3 --- /dev/null +++ b/tests/integration/test_post.py @@ -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 diff --git a/tests/integration/test_read.py b/tests/integration/test_read.py new file mode 100644 index 0000000..4a85792 --- /dev/null +++ b/tests/integration/test_read.py @@ -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"] diff --git a/tests/integration/test_status.py b/tests/integration/test_status.py new file mode 100644 index 0000000..8e609a6 --- /dev/null +++ b/tests/integration/test_status.py @@ -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 diff --git a/tests/integration/test_tags.py b/tests/integration/test_tags.py new file mode 100644 index 0000000..9df84ce --- /dev/null +++ b/tests/integration/test_tags.py @@ -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)) diff --git a/tests/integration/test_timelines.py b/tests/integration/test_timelines.py new file mode 100644 index 0000000..8268961 --- /dev/null +++ b/tests/integration/test_timelines.py @@ -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()) diff --git a/tests/integration/test_update_account.py b/tests/integration/test_update_account.py new file mode 100644 index 0000000..6c1dd60 --- /dev/null +++ b/tests/integration/test_update_account.py @@ -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 diff --git a/tests/test_api.py b/tests/test_api.py deleted file mode 100644 index 65f815a..0000000 --- a/tests/test_api.py +++ /dev/null @@ -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) diff --git a/tests/test_auth.py b/tests/test_auth.py deleted file mode 100644 index e8e3301..0000000 --- a/tests/test_auth.py +++ /dev/null @@ -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 -# diff --git a/tests/test_config.py b/tests/test_config.py index 40055a0..d2a59cf 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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']) diff --git a/tests/test_console.py b/tests/test_console.py deleted file mode 100644 index f59cf5f..0000000 --- a/tests/test_console.py +++ /dev/null @@ -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': "

The computer can't tell you the emotional story. It can give you the exact mathematical design, but what's missing is the eyebrows.

", - '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': "

The computer can't tell you the emotional story. It can give you the exact mathematical design, but what's missing is the eyebrows.

", - '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': "

We still have fans in 2017 @fan123

", - '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': "

The Black Page, a masterpiece

", - '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': "

The Black Page, a masterpiece

", - '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 diff --git a/tests/test_integration.py b/tests/test_integration.py deleted file mode 100644 index 49a7a78..0000000 --- a/tests/test_integration.py +++ /dev/null @@ -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 diff --git a/tests/test_output.py b/tests/test_output.py deleted file mode 100644 index cc31e5c..0000000 --- a/tests/test_output.py +++ /dev/null @@ -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("foo") == f"{red}foo{reset}{reset}" - assert colorize("foo bar baz") == f"foo {red}bar{reset} baz{reset}" - assert colorize("foo bar baz") == f"foo {red}{bold}bar{reset} baz{reset}" - assert colorize("foo bar baz") == f"foo {red}{bold}bar{reset}{bold} baz{reset}" - assert colorize("foo bar baz") == f"foo {red}{bold}bar{reset} baz{reset}" - assert colorize("foobarbaz") == f"{red}foo{bold}bar{reset}{red}baz{reset}{reset}" - - -def test_strip_tags(): - assert strip_tags("foo") == "foo" - assert strip_tags("foo") == "foo" - assert strip_tags("foo bar baz") == "foo bar baz" - assert strip_tags("foo bar baz") == "foo bar baz" - assert strip_tags("foo bar baz") == "foo bar baz" - assert strip_tags("foo bar baz") == "foo bar baz" - assert strip_tags("foobarbaz") == "foobarbaz" diff --git a/tests/test_utils.py b/tests/test_utils.py index a1bba9a..906a351 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -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" diff --git a/tests/tui/test_rich_text.py b/tests/tui/test_rich_text.py new file mode 100644 index 0000000..bf04d4b --- /dev/null +++ b/tests/tui/test_rich_text.py @@ -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 = """ +

foo

+

foo bar baz

+ """.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" diff --git a/tests/utils.py b/tests/utils.py index cdae09c..817bdb9 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -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() diff --git a/toot/__init__.py b/toot/__init__.py index 459cafc..2a3f4ab 100644 --- a/toot/__init__.py +++ b/toot/__init__.py @@ -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) diff --git a/toot/__main__.py b/toot/__main__.py new file mode 100644 index 0000000..fa3e807 --- /dev/null +++ b/toot/__main__.py @@ -0,0 +1,3 @@ +from toot.cli import cli + +cli() diff --git a/toot/api.py b/toot/api.py index df008d5..470eabb 100644 --- a/toot/api.py +++ b/toot/api.py @@ -1,28 +1,56 @@ +import mimetypes import re import uuid +from os import path +from requests import Response +from typing import BinaryIO, List, Optional from urllib.parse import urlparse, urlencode, quote -from toot import http, CLIENT_NAME, CLIENT_WEBSITE -from toot.exceptions import AuthenticationError, ApiError -from toot.utils import str_bool +from toot import App, User, http, CLIENT_NAME, CLIENT_WEBSITE +from toot.exceptions import AuthenticationError, ApiError, ConsoleError +from toot.utils import drop_empty_values, str_bool, str_bool_nullable + SCOPES = 'read write follow' -def _account_action(app, user, account, action): +def find_account(app, user, account_name): + if not account_name: + raise ConsoleError("Empty account name given") + + normalized_name = account_name.lstrip("@").lower() + + # Strip @ 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) diff --git a/toot/auth.py b/toot/auth.py index 05b61b6..ef84652 100644 --- a/toot/auth.py +++ b/toot/auth.py @@ -1,112 +1,74 @@ -import sys -import webbrowser - -from builtins import input -from getpass import getpass - -from toot import api, config, DEFAULT_INSTANCE, User, App +from toot import api, config, User, App +from toot.entities import from_dict, Instance from toot.exceptions import ApiError, ConsoleError -from toot.output import print_out +from urllib.parse import urlparse -def register_app(domain, scheme='https'): - print_out("Looking up instance info...") - instance = api.get_instance(domain, scheme) - - print_out("Found instance {} running Mastodon version {}".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 [{}]: ".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: {}".format( - config.get_config_file_path())) - return user -def login_interactive(app, email=None): - print_out("Log in to {}".format(app.instance)) - - if email: - print_out("Email: {}".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: read from stdin") - +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 toot to access -your account. When you do, you will be given an authorization code -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 diff --git a/toot/cli/__init__.py b/toot/cli/__init__.py new file mode 100644 index 0000000..a6af85a --- /dev/null +++ b/toot/cli/__init__.py @@ -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 diff --git a/toot/cli/accounts.py b/toot/cli/accounts.py new file mode 100644 index 0000000..01c499d --- /dev/null +++ b/toot/cli/accounts.py @@ -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") diff --git a/toot/cli/auth.py b/toot/cli/auth.py new file mode 100644 index 0000000..b7d32e6 --- /dev/null +++ b/toot/cli/auth.py @@ -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]) diff --git a/toot/cli/lists.py b/toot/cli/lists.py new file mode 100644 index 0000000..f0d4ae6 --- /dev/null +++ b/toot/cli/lists.py @@ -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] diff --git a/toot/cli/post.py b/toot/cli/post.py new file mode 100644 index 0000000..8d54dd1 --- /dev/null +++ b/toot/cli/post.py @@ -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"]) diff --git a/toot/cli/read.py b/toot/cli/read.py new file mode 100644 index 0000000..32ce49a --- /dev/null +++ b/toot/cli/read.py @@ -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) diff --git a/toot/cli/statuses.py b/toot/cli/statuses.py new file mode 100644 index 0000000..0d088b2 --- /dev/null +++ b/toot/cli/statuses.py @@ -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") diff --git a/toot/cli/tags.py b/toot/cli/tags.py new file mode 100644 index 0000000..b79fdf0 --- /dev/null +++ b/toot/cli/tags.py @@ -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") diff --git a/toot/cli/timelines.py b/toot/cli/timelines.py new file mode 100644 index 0000000..aac2c45 --- /dev/null +++ b/toot/cli/timelines.py @@ -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 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 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"] diff --git a/toot/cli/tui.py b/toot/cli/tui.py new file mode 100644 index 0000000..c13b745 --- /dev/null +++ b/toot/cli/tui.py @@ -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() diff --git a/toot/cli/validators.py b/toot/cli/validators.py new file mode 100644 index 0000000..6b7c8fe --- /dev/null +++ b/toot/cli/validators.py @@ -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)}") diff --git a/toot/commands.py b/toot/commands.py deleted file mode 100644 index 69c9c58..0000000 --- a/toot/commands.py +++ /dev/null @@ -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: {scheduled_at}") - else: - print_out(f"Toot posted: {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 {} 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("✓ Status deleted") - - -def favourite(app, user, args): - api.favourite(app, user, args.status_id) - print_out("✓ Status favourited") - - -def unfavourite(app, user, args): - api.unfavourite(app, user, args.status_id) - print_out("✓ Status unfavourited") - - -def reblog(app, user, args): - api.reblog(app, user, args.status_id, visibility=args.visibility) - print_out("✓ Status reblogged") - - -def unreblog(app, user, args): - api.unreblog(app, user, args.status_id) - print_out("✓ Status unreblogged") - - -def pin(app, user, args): - api.pin(app, user, args.status_id) - print_out("✓ Status pinned") - - -def unpin(app, user, args): - api.unpin(app, user, args.status_id) - print_out("✓ Status unpinned") - - -def bookmark(app, user, args): - api.bookmark(app, user, args.status_id) - print_out("✓ Status bookmarked") - - -def unbookmark(app, user, args): - api.unbookmark(app, user, args.status_id) - print_out("✓ Status unbookmarked") - - -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("* {} {}".format(uid, active_label)) - - path = config.get_config_file_path() - print_out("\nAuth tokens are stored in: {}".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("✓ Successfully logged in.") - - -def login(app, user, args): - app = create_app_interactive(instance=args.instance, scheme=args.scheme) - login_browser_interactive(app) - - print_out() - print_out("✓ Successfully logged in.") - - -def logout(app, user, args): - user = config.load_user(args.account, throw=True) - config.delete_user(user) - print_out("✓ User {} logged out".format(config.user_id(user))) - - -def activate(app, user, args): - user = config.load_user(args.account, throw=True) - config.activate_user(user) - print_out("✓ User {} active".format(config.user_id(user))) - - -def upload(app, user, args): - response = _do_upload(app, user, args.file, args.description) - - msg = "Successfully uploaded media ID {}, type '{}'" - - print_out() - print_out(msg.format(response['id'], response['type'])) - print_out("URL: {}".format(response['url'])) - print_out("Preview URL: {}".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: {}".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 @ 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("✓ You are now following {}".format(args.account)) - - -def unfollow(app, user, args): - account = _find_account(app, user, args.account) - api.unfollow(app, user, account['id']) - print_out("✓ You are no longer following {}".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("✓ You are now following #{}".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("✓ You are no longer following #{}".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("✓ You have muted {}".format(args.account)) - - -def unmute(app, user, args): - account = _find_account(app, user, args.account) - api.unmute(app, user, account['id']) - print_out("✓ {} is no longer muted".format(args.account)) - - -def block(app, user, args): - account = _find_account(app, user, args.account) - api.block(app, user, account['id']) - print_out("✓ You are now blocking {}".format(args.account)) - - -def unblock(app, user, args): - account = _find_account(app, user, args.account) - api.unblock(app, user, account['id']) - print_out("✓ {} is no longer blocked".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("Cleared notifications") - 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("No notification") - 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() diff --git a/toot/config.py b/toot/config.py index 6404467..8817e7f 100644 --- a/toot/config.py +++ b/toot/config.py @@ -1,44 +1,22 @@ import json import os -import sys -from functools import wraps -from os.path import dirname, join, expanduser +from contextlib import contextmanager +from os.path import dirname, join +from typing import Optional -from toot import User, App +from toot import User, App, get_config_dir from toot.exceptions import ConsoleError -from toot.output import print_out -TOOT_CONFIG_DIR_NAME = "toot" TOOT_CONFIG_FILE_NAME = "config.json" -def get_config_dir(): - """Returns the path to toot config directory""" - - # On Windows, store the config in roaming appdata - if sys.platform == "win32" and "APPDATA" in os.environ: - return join(os.getenv("APPDATA"), TOOT_CONFIG_DIR_NAME) - - # Respect XDG_CONFIG_HOME env variable if set - # https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html - if "XDG_CONFIG_HOME" in os.environ: - config_home = expanduser(os.environ["XDG_CONFIG_HOME"]) - return join(config_home, TOOT_CONFIG_DIR_NAME) - - # Default to ~/.config/toot/ - return join(expanduser("~"), ".config", TOOT_CONFIG_DIR_NAME) - - def get_config_file_path(): """Returns the path to toot config file.""" return join(get_config_dir(), TOOT_CONFIG_FILE_NAME) -CONFIG_FILE = get_config_file_path() - - def user_id(user): return "{}@{}".format(user.username, user.instance) @@ -51,8 +29,6 @@ def make_config(path): "active_user": None, } - print_out("Creating config file at {}".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 diff --git a/toot/console.py b/toot/console.py deleted file mode 100644 index 72743fe..0000000 --- a/toot/console.py +++ /dev/null @@ -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 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("{}".format(CLIENT_NAME)) - print_out("v{}".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(" toot {} {}".format(cmd_name, cmd.description)) - - print_out("") - print_out("To get help for each command run:") - print_out(" toot \\ --help") - print_out("") - print_out("{}".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 toot --help 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 diff --git a/toot/entities.py b/toot/entities.py new file mode 100644 index 0000000..309d84e --- /dev/null +++ b/toot/entities.py @@ -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[]` returns the encapsulated ``.""" + 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 diff --git a/toot/exceptions.py b/toot/exceptions.py index 2bf495d..c5e2350 100644 --- a/toot/exceptions.py +++ b/toot/exceptions.py @@ -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.""" diff --git a/toot/http.py b/toot/http.py index b60a956..ec4b62a 100644 --- a/toot/http.py +++ b/toot/http.py @@ -3,7 +3,7 @@ from requests.exceptions import RequestException from toot import __version__ from toot.exceptions import NotFoundError, ApiError -from toot.logging import log_request, log_response +from toot.logging import log_request, log_request_exception, log_response def send_request(request, allow_redirects=True): @@ -19,6 +19,7 @@ def send_request(request, allow_redirects=True): settings = session.merge_environment_settings(prepared.url, {}, None, None, None) response = session.send(prepared, allow_redirects=allow_redirects, **settings) except RequestException as ex: + log_request_exception(request, ex) raise ApiError(f"Request failed: {str(ex)}") log_response(response) @@ -37,7 +38,7 @@ def _get_error_message(response): except Exception: pass - return "Unknown error" + return f"Unknown error: {response.status_code} {response.reason}" def process_response(response): @@ -80,13 +81,41 @@ def post(app, user, path, headers=None, files=None, data=None, json=None, allow_ return anon_post(url, headers=headers, files=files, data=data, json=json, allow_redirects=allow_redirects) -def delete(app, user, path, data=None, headers=None): +def anon_put(url, headers=None, files=None, data=None, json=None, allow_redirects=True): + request = Request(method="PUT", url=url, headers=headers, files=files, data=data, json=json) + response = send_request(request, allow_redirects) + + return process_response(response) + + +def put(app, user, path, headers=None, files=None, data=None, json=None, allow_redirects=True): url = app.base_url + path headers = headers or {} headers["Authorization"] = f"Bearer {user.access_token}" - request = Request('DELETE', url, headers=headers, json=data) + return anon_put(url, headers=headers, files=files, data=data, json=json, allow_redirects=allow_redirects) + + +def patch(app, user, path, headers=None, files=None, data=None, json=None): + url = app.base_url + path + + headers = headers or {} + headers["Authorization"] = f"Bearer {user.access_token}" + + request = Request('PATCH', url, headers=headers, files=files, data=data, json=json) + response = send_request(request) + + return process_response(response) + + +def delete(app, user, path, data=None, json=None, headers=None): + url = app.base_url + path + + headers = headers or {} + headers["Authorization"] = f"Bearer {user.access_token}" + + request = Request('DELETE', url, headers=headers, data=data, json=json) response = send_request(request) return process_response(response) diff --git a/toot/logging.py b/toot/logging.py index 4c6e382..a19ad2e 100644 --- a/toot/logging.py +++ b/toot/logging.py @@ -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 diff --git a/toot/output.py b/toot/output.py index 5be6a92..bc85c0d 100644 --- a/toot/output.py +++ b/toot/output.py @@ -1,342 +1,340 @@ -import os +import click import re -import sys import textwrap +import shutil -from toot.tui.utils import parse_datetime +from toot.entities import Account, Instance, Notification, Poll, Status +from toot.utils import get_text, html_to_paragraphs +from toot.wcstring import wc_wrap +from typing import Any, Generator, Iterable, List from wcwidth import wcswidth -from toot.utils import get_text, parse_html -from toot.wcstring import wc_wrap + +DEFAULT_WIDTH = 80 -STYLES = { - 'reset': '\033[0m', - 'bold': '\033[1m', - 'dim': '\033[2m', - 'italic': '\033[3m', - 'underline': '\033[4m', - 'red': '\033[91m', - 'green': '\033[92m', - 'yellow': '\033[93m', - 'blue': '\033[94m', - 'magenta': '\033[95m', - 'cyan': '\033[96m', -} - -STYLE_TAG_PATTERN = re.compile(r""" - (? # 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.: - - alert! a thing happened - - Empty closing tag will reset all styes: - - alert! a thing happened - - Styles can be nested: - - red red and underline 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. "foobarbaz" - 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"{a}" 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"{instance['title']}") - print_out(f"{instance['uri']}") - 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"@{account['acct']} {account['display_name']}") - - if account["note"]: - print_out("") - print_html(account["note"]) - - print_out("") - print_out(f"ID: {account['id']}") - print_out(f"Since: {account['created_at'][:10]}") - print_out("") - print_out(f"Followers: {account['followers_count']}") - print_out(f"Following: {account['following_count']}") - print_out(f"Statuses: {account['statuses_count']}") - - if account["fields"]: - for field in account["fields"]: - name = field["name"].title() - print_out(f'\n{name}:') - print_html(field["value"]) - if field["verified_at"]: - print_out("✓ Verified") - - 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'(? str: + return "\n".join(account_lines(account, width)) -def highlight_hashtags(line): - return re.sub(HASHTAG_PATTERN, '\\1', 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"* @{account['acct']} {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"* #{tag['name']}\t{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"#{t['name']}" 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("Nothing found") + 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"{display_name}" if display_name else "", - f"{username}", - " " * spacing, - f"{time}", - ) + 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 {status['id']} ", - f"↲ In reply to {in_reply_to} " if in_reply_to else "", - f"↻ Reblogged @{reblog['account']['acct']} " 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 = " " + 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") diff --git a/toot/settings.py b/toot/settings.py new file mode 100644 index 0000000..4da3322 --- /dev/null +++ b/toot/settings.py @@ -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 diff --git a/toot/tui/app.py b/toot/tui/app.py index 16c284f..9e6521c 100644 --- a/toot/tui/app.py +++ b/toot/tui/app.py @@ -1,25 +1,43 @@ import logging +import subprocess import urwid from concurrent.futures import ThreadPoolExecutor +from typing import NamedTuple, Optional +from datetime import datetime, timezone -from toot import api, config, __version__ -from toot.console import get_default_visibility +from toot import api, config, __version__, settings +from toot import App, User +from toot.cli import get_default_visibility from toot.exceptions import ApiError +from toot.utils.datetime import parse_datetime from .compose import StatusComposer from .constants import PALETTE from .entities import Status from .overlays import ExceptionStackTrace, GotoMenu, Help, StatusSource, StatusLinks, StatusZoom -from .overlays import StatusDeleteConfirmation +from .overlays import StatusDeleteConfirmation, Account +from .poll import Poll from .timeline import Timeline -from .utils import parse_content_links, show_media +from .utils import get_max_toot_chars, parse_content_links, copy_to_clipboard +from .widgets import ModalBox, RoundedLineBox logger = logging.getLogger(__name__) urwid.set_encoding('UTF-8') +DEFAULT_MAX_TOOT_CHARS = 500 + + +class TuiOptions(NamedTuple): + colors: int + media_viewer: Optional[str] + always_show_sensitive: bool + relative_datetimes: bool + default_visibility: Optional[bool] + + class Header(urwid.WidgetWrap): def __init__(self, app, user): self.app = app @@ -71,29 +89,41 @@ class Footer(urwid.Pile): class TUI(urwid.Frame): """Main TUI frame.""" + loop: urwid.MainLoop + screen: urwid.BaseScreen - @classmethod - def create(cls, app, user, args): + @staticmethod + def create(app: App, user: User, args: TuiOptions): """Factory method, sets up TUI and an event loop.""" + screen = urwid.raw_display.Screen() + screen.set_terminal_properties(args.colors) + + tui = TUI(app, user, screen, args) + + palette = PALETTE.copy() + overrides = settings.get_setting("tui.palette", dict, {}) + for name, styles in overrides.items(): + palette.append(tuple([name] + styles)) - tui = cls(app, user, args) loop = urwid.MainLoop( tui, - palette=PALETTE, + palette=palette, event_loop=urwid.AsyncioEventLoop(), unhandled_input=tui.unhandled_input, + screen=screen, ) tui.loop = loop return tui - def __init__(self, app, user, args): + def __init__(self, app, user, screen, options: TuiOptions): self.app = app self.user = user - self.args = args self.config = config.load_config() + self.options = options - self.loop = None # set in `create` + self.loop = None # late init, set in `create` + self.screen = screen self.executor = ThreadPoolExecutor(max_workers=1) self.timeline_generator = api.home_timeline_generator(app, user, limit=40) @@ -104,20 +134,24 @@ class TUI(urwid.Frame): self.footer.set_status("Loading...") # Default max status length, updated on startup - self.max_toot_chars = 500 + self.max_toot_chars = DEFAULT_MAX_TOOT_CHARS self.timeline = None self.overlay = None self.exception = None self.can_translate = False + self.account = None + self.followed_accounts = [] + self.preferences = {} super().__init__(self.body, header=self.header, footer=self.footer) def run(self): self.loop.set_alarm_in(0, lambda *args: self.async_load_instance()) - self.loop.set_alarm_in(0, lambda *args: self.async_load_followed_tags()) + self.loop.set_alarm_in(0, lambda *args: self.async_load_preferences()) self.loop.set_alarm_in(0, lambda *args: self.async_load_timeline( is_initial=True, timeline_name="home")) + self.loop.set_alarm_in(0, lambda *args: self.async_load_followed_accounts()) self.loop.run() self.executor.shutdown(wait=False) @@ -145,8 +179,8 @@ class TUI(urwid.Frame): return urwid.Filler(intro) - def run_in_thread(self, fn, args=[], kwargs={}, done_callback=None, error_callback=None): - """Runs `fn(*args, **kwargs)` asynchronously in a separate thread. + def run_in_thread(self, fn, done_callback=None, error_callback=None): + """Runs `fn` asynchronously in a separate thread. On completion calls `done_callback` if `fn` exited cleanly, or `error_callback` if an exception was caught. Callback methods are @@ -155,7 +189,7 @@ class TUI(urwid.Frame): def _default_error_callback(ex): self.exception = ex - self.footer.set_error_message("An exception occurred, press E to view") + self.footer.set_error_message("An exception occurred, press X to view") _error_callback = error_callback or _default_error_callback @@ -170,53 +204,15 @@ class TUI(urwid.Frame): logger.exception(exception) self.loop.set_alarm_in(0, lambda *args: _error_callback(exception)) - future = self.executor.submit(fn, *args, **kwargs) + # TODO: replace by `self.loop.event_loop.run_in_executor` at some point + # Added in https://github.com/urwid/urwid/issues/575 + # Not yet released at the time of this comment + future = self.loop.event_loop._loop.run_in_executor(self.executor, fn) future.add_done_callback(_done) return future def connect_default_timeline_signals(self, timeline): - def _compose(*args): - self.show_compose() - - def _delete(timeline, status): - if status.is_mine: - self.show_delete_confirmation(status) - - def _reply(timeline, status): - self.show_compose(status) - - def _source(timeline, status): - self.show_status_source(status) - - def _links(timeline, status): - self.show_links(status) - - def _media(timeline, status): - self.show_media(status) - - def _menu(timeline, status): - self.show_context_menu(status) - - def _zoom(timeline, status_details): - self.show_status_zoom(status_details) - - def _clear(*args): - self.clear_screen() - - urwid.connect_signal(timeline, "bookmark", self.async_toggle_bookmark) - urwid.connect_signal(timeline, "compose", _compose) - urwid.connect_signal(timeline, "delete", _delete) - urwid.connect_signal(timeline, "favourite", self.async_toggle_favourite) urwid.connect_signal(timeline, "focus", self.refresh_footer) - urwid.connect_signal(timeline, "media", _media) - urwid.connect_signal(timeline, "menu", _menu) - urwid.connect_signal(timeline, "reblog", self.async_toggle_reblog) - urwid.connect_signal(timeline, "reply", _reply) - urwid.connect_signal(timeline, "source", _source) - urwid.connect_signal(timeline, "links", _links) - urwid.connect_signal(timeline, "zoom", _zoom) - urwid.connect_signal(timeline, "translate", self.async_translate) - urwid.connect_signal(timeline, "clear-screen", _clear) def build_timeline(self, name, statuses, local): def _close(*args): @@ -225,9 +221,6 @@ class TUI(urwid.Frame): def _next(*args): self.async_load_timeline(is_initial=False) - def _thread(timeline, status): - self.show_thread(status) - def _toggle_save(timeline, status): if not timeline.name.startswith("#"): return @@ -243,12 +236,11 @@ class TUI(urwid.Frame): self.loop.set_alarm_in(5, lambda *args: self.footer.clear_message()) config.save_config(self.config) - timeline = Timeline(name, statuses, self.can_translate, self.followed_tags) + timeline = Timeline(self, name, statuses) self.connect_default_timeline_signals(timeline) urwid.connect_signal(timeline, "next", _next) urwid.connect_signal(timeline, "close", _close) - urwid.connect_signal(timeline, "thread", _thread) urwid.connect_signal(timeline, "save", _toggle_save) return timeline @@ -266,19 +258,18 @@ class TUI(urwid.Frame): # This is pretty fast, so it's probably ok to block while context is # loaded, can be made async later if needed - context = api.context(self.app, self.user, status.original.id) + context = api.context(self.app, self.user, status.original.id).json() ancestors = [self.make_status(s) for s in context["ancestors"]] descendants = [self.make_status(s) for s in context["descendants"]] statuses = ancestors + [status] + descendants focus = len(ancestors) - timeline = Timeline("thread", statuses, self.can_translate, - self.followed_tags, focus, is_thread=True) + timeline = Timeline(self, "thread", statuses, focus=focus, is_thread=True) self.connect_default_timeline_signals(timeline) urwid.connect_signal(timeline, "close", _close) - self.body = timeline + timeline.refresh_status_details() self.refresh_footer(timeline) def async_load_timeline(self, is_initial, timeline_name=None, local=None): @@ -322,11 +313,11 @@ class TUI(urwid.Frame): See: https://github.com/mastodon/mastodon/issues/19328 """ def _load_instance(): - return api.get_instance(self.app.instance) + return api.get_instance(self.app.base_url).json() def _done(instance): - if "max_toot_chars" in instance: - self.max_toot_chars = instance["max_toot_chars"] + self.max_toot_chars = get_max_toot_chars(instance, DEFAULT_MAX_TOOT_CHARS) + logger.info(f"Max toot chars set to: {self.max_toot_chars}") if "translation" in instance: # instance is advertising translation service @@ -342,21 +333,33 @@ class TUI(urwid.Frame): return self.run_in_thread(_load_instance, done_callback=_done) - def async_load_followed_tags(self): - def _load_tag_list(): + def async_load_preferences(self): + """ + Attempt to update user preferences from instance. + https://docs.joinmastodon.org/methods/preferences/ + """ + def _load_preferences(): + return api.get_preferences(self.app, self.user).json() + + def _done(preferences): + self.preferences = preferences + + return self.run_in_thread(_load_preferences, done_callback=_done) + + def async_load_followed_accounts(self): + def _load_accounts(): try: - return api.followed_tags(self.app, self.user) + acct = f'@{self.user.username}@{self.user.instance}' + self.account = api.find_account(self.app, self.user, acct) + return api.following(self.app, self.user, self.account["id"]) except ApiError: # not supported by all Mastodon servers so fail silently if necessary return [] - def _done_tag_list(tags): - if len(tags) > 0: - self.followed_tags = [t["name"] for t in tags] - else: - self.followed_tags = [] + def _done_accounts(accounts): + self.followed_accounts = {a["acct"] for a in accounts} - self.run_in_thread(_load_tag_list, done_callback=_done_tag_list) + self.run_in_thread(_load_accounts, done_callback=_done_accounts) def refresh_footer(self, timeline): """Show status details in footer.""" @@ -373,11 +376,11 @@ class TUI(urwid.Frame): ) def clear_screen(self): - self.loop.screen.clear() + self.screen.clear() def show_links(self, status): - links = parse_content_links(status.data["content"]) if status else [] - post_attachments = status.data["media_attachments"] or [] + links = parse_content_links(status.original.data["content"]) if status else [] + post_attachments = status.original.data["media_attachments"] or [] reblog_attachments = (status.data["reblog"]["media_attachments"] if status.data["reblog"] else None) or [] for a in post_attachments + reblog_attachments: @@ -388,6 +391,8 @@ class TUI(urwid.Frame): self.clear_screen() if links: + links = list(set(links)) # deduplicate links + links = sorted(links, key=lambda link: link[0]) # sort alphabetically by URL sl_widget = StatusLinks(links) urwid.connect_signal(sl_widget, "clear-screen", _clear) self.open_overlay( @@ -415,32 +420,81 @@ class TUI(urwid.Frame): def _post(timeline, *args): self.post_status(*args) - composer = StatusComposer(self.max_toot_chars, self.user.username, in_reply_to) + # If the user specified --default-visibility, use that; otherwise, + # try to use the server-side default visibility. If that fails, fall + # back to get_default_visibility(). + visibility = (self.options.default_visibility or + self.preferences.get('posting:default:visibility', + get_default_visibility())) + + composer = StatusComposer(self.max_toot_chars, self.user.username, + visibility, in_reply_to) urwid.connect_signal(composer, "close", _close) urwid.connect_signal(composer, "post", _post) self.open_overlay(composer, title="Compose status") + def async_edit(self, status): + def _fetch_source(): + return api.fetch_status_source(self.app, self.user, status.id).json() + + def _done(source): + self.close_overlay() + self.show_edit(status, source) + + please_wait = ModalBox("Loading status...") + self.open_overlay(please_wait) + + self.run_in_thread(_fetch_source, done_callback=_done) + + def show_edit(self, status, source): + def _close(*args): + self.close_overlay() + + def _edit(timeline, *args): + self.edit_status(status, *args) + + composer = StatusComposer(self.max_toot_chars, self.user.username, + visibility=None, edit=status, source=source) + urwid.connect_signal(composer, "close", _close) + urwid.connect_signal(composer, "post", _edit) + self.open_overlay(composer, title="Edit status") + def show_goto_menu(self): user_timelines = self.config.get("timelines", {}) - menu = GotoMenu(user_timelines) + user_lists = api.get_lists(self.app, self.user) or [] + + menu = GotoMenu(user_timelines, user_lists) urwid.connect_signal(menu, "home_timeline", lambda x: self.goto_home_timeline()) urwid.connect_signal(menu, "public_timeline", lambda x, local: self.goto_public_timeline(local)) urwid.connect_signal(menu, "bookmark_timeline", lambda x, local: self.goto_bookmarks()) - + urwid.connect_signal(menu, "notification_timeline", + lambda x, local: self.goto_notifications()) + urwid.connect_signal(menu, "conversation_timeline", + lambda x, local: self.goto_conversations()) + urwid.connect_signal(menu, "personal_timeline", + lambda x, local: self.goto_personal_timeline()) urwid.connect_signal(menu, "hashtag_timeline", lambda x, tag, local: self.goto_tag_timeline(tag, local=local)) + urwid.connect_signal(menu, "list_timeline", + lambda x, list_item: self.goto_list_timeline(list_item)) self.open_overlay(menu, title="Go to", options=dict( align="center", width=("relative", 60), - valign="middle", height=10 + len(user_timelines), + valign="middle", height=18 + len(user_timelines) + len(user_lists), )) def show_help(self): self.open_overlay(Help(), title="Help") + def show_poll(self, status): + self.open_overlay( + widget=Poll(self.app, self.user, status), + title="Poll", + ) + def goto_home_timeline(self): self.timeline_generator = api.home_timeline_generator( self.app, self.user, limit=40) @@ -450,7 +504,8 @@ class TUI(urwid.Frame): def goto_public_timeline(self, local): self.timeline_generator = api.public_timeline_generator( self.app, self.user, local=local, limit=40) - promise = self.async_load_timeline(is_initial=True, timeline_name="public") + timeline_name = "local public" if local else "global public" + promise = self.async_load_timeline(is_initial=True, timeline_name=timeline_name) promise.add_done_callback(lambda *args: self.close_overlay()) def goto_bookmarks(self): @@ -459,6 +514,21 @@ class TUI(urwid.Frame): promise = self.async_load_timeline(is_initial=True, timeline_name="bookmarks") promise.add_done_callback(lambda *args: self.close_overlay()) + def goto_notifications(self): + self.timeline_generator = api.notification_timeline_generator( + self.app, self.user, limit=40) + promise = self.async_load_timeline(is_initial=True, timeline_name="notifications") + promise.add_done_callback(lambda *args: self.close_overlay()) + + def goto_conversations(self): + self.timeline_generator = api.conversation_timeline_generator( + self.app, self.user, limit=40 + ) + promise = self.async_load_timeline( + is_initial=True, timeline_name="conversations" + ) + promise.add_done_callback(lambda *args: self.close_overlay()) + def goto_tag_timeline(self, tag, local): self.timeline_generator = api.tag_timeline_generator( self.app, self.user, tag, local=local, limit=40) @@ -467,10 +537,37 @@ class TUI(urwid.Frame): ) promise.add_done_callback(lambda *args: self.close_overlay()) + def goto_personal_timeline(self): + account_name = f"{self.user.username}@{self.user.instance}" + + self.timeline_generator = api.account_timeline_generator( + self.app, self.user, account_name, reblogs=True, limit=40) + promise = self.async_load_timeline(is_initial=True, timeline_name=f"personal {account_name}") + promise.add_done_callback(lambda *args: self.close_overlay()) + + def goto_list_timeline(self, list_item): + self.timeline_generator = api.timeline_list_generator( + self.app, self.user, list_item['id'], limit=40) + promise = self.async_load_timeline( + is_initial=True, timeline_name=f"\N{clipboard}{list_item['title']}") + promise.add_done_callback(lambda *args: self.close_overlay()) + def show_media(self, status): urls = [m["url"] for m in status.original.data["media_attachments"]] - if urls: - show_media(urls) + if not urls: + return + + media_viewer = self.options.media_viewer + if media_viewer: + try: + subprocess.run([media_viewer] + urls) + except FileNotFoundError: + self.footer.set_error_message(f"Media viewer not found: '{media_viewer}'") + except Exception as ex: + self.exception = ex + self.footer.set_error_message("Failed invoking media viewer. Press X to see exception.") + else: + self.footer.set_error_message("Media viewer not configured") def show_context_menu(self, status): # TODO: show context menu @@ -488,15 +585,20 @@ class TUI(urwid.Frame): urwid.connect_signal(widget, "close", _close) urwid.connect_signal(widget, "delete", _delete) self.open_overlay(widget, title="Delete status?", options=dict( - align="center", width=("relative", 60), - valign="middle", height=5, + align="center", width=30, + valign="middle", height=4, )) def post_status(self, content, warning, visibility, in_reply_to_id): - data = api.post_status(self.app, self.user, content, + data = api.post_status( + self.app, + self.user, + content, spoiler_text=warning, visibility=visibility, - in_reply_to_id=in_reply_to_id) + in_reply_to_id=in_reply_to_id + ).json() + status = self.make_status(data) # TODO: fetch new items from the timeline? @@ -504,13 +606,55 @@ class TUI(urwid.Frame): self.footer.set_message("Status posted {} \\o/".format(status.id)) self.close_overlay() + def edit_status(self, status, content, warning, visibility, in_reply_to_id): + # We don't support editing polls (yet), so to avoid losing the poll + # data from the original toot, copy it to the edit request. + poll_args = {} + poll = status.original.data.get('poll', None) + + if poll is not None: + poll_args['poll_options'] = [o['title'] for o in poll['options']] + poll_args['poll_multiple'] = poll['multiple'] + + # Convert absolute expiry time into seconds from now. + expires_at = parse_datetime(poll['expires_at']) + expires_in = int((expires_at - datetime.now(timezone.utc)).total_seconds()) + poll_args['poll_expires_in'] = expires_in + + if 'hide_totals' in poll: + poll_args['poll_hide_totals'] = poll['hide_totals'] + + data = api.edit_status( + self.app, + self.user, + status.id, + content, + spoiler_text=warning, + visibility=visibility, + **poll_args + ).json() + + new_status = self.make_status(data) + + self.footer.set_message("Status edited {} \\o/".format(status.id)) + self.close_overlay() + + if self.timeline is not None: + self.timeline.update_status(new_status) + + def show_account(self, account_id): + account = api.whois(self.app, self.user, account_id) + relationship = api.get_relationship(self.app, self.user, account_id) + self.open_overlay( + widget=Account(self.app, self.user, account, relationship), + title="Account", + ) + def async_toggle_favourite(self, timeline, status): def _favourite(): - logger.info("Favouriting {}".format(status)) api.favourite(self.app, self.user, status.id) def _unfavourite(): - logger.info("Unfavouriting {}".format(status)) api.unfavourite(self.app, self.user, status.id) def _done(loop): @@ -527,18 +671,16 @@ class TUI(urwid.Frame): def async_toggle_reblog(self, timeline, status): def _reblog(): - logger.info("Reblogging {}".format(status)) - api.reblog(self.app, self.user, status.id, visibility=get_default_visibility()) + api.reblog(self.app, self.user, status.original.id, visibility=get_default_visibility()) def _unreblog(): - logger.info("Unreblogging {}".format(status)) - api.unreblog(self.app, self.user, status.id) + api.unreblog(self.app, self.user, status.original.id) def _done(loop): # Create a new Status with flipped reblogged flag new_data = status.data - new_data["reblogged"] = not status.reblogged new_status = self.make_status(new_data) + new_status.original.reblogged = not status.original.reblogged timeline.update_status(new_status) # Check if status is rebloggable @@ -549,17 +691,16 @@ class TUI(urwid.Frame): return self.run_in_thread( - _unreblog if status.reblogged else _reblog, + _unreblog if status.original.reblogged else _reblog, done_callback=_done ) def async_translate(self, timeline, status): def _translate(): - logger.info("Translating {}".format(status)) - self.footer.set_message("Translating status {}".format(status.id)) + self.footer.set_message("Translating status {}".format(status.original.id)) try: - response = api.translate(self.app, self.user, status.id) + response = api.translate(self.app, self.user, status.original.id) if response["content"]: self.footer.set_message("Status translated") else: @@ -574,25 +715,23 @@ class TUI(urwid.Frame): def _done(response): if response is not None: - status.translation = response["content"] - status.translated_from = response["detected_source_language"] - status.show_translation = True + status.original.translation = response["content"] + status.original.translated_from = response["detected_source_language"] + status.original.show_translation = True timeline.update_status(status) # If already translated, toggle showing translation - if status.translation: - status.show_translation = not status.show_translation + if status.original.translation: + status.original.show_translation = not status.original.show_translation timeline.update_status(status) else: self.run_in_thread(_translate, done_callback=_done) def async_toggle_bookmark(self, timeline, status): def _bookmark(): - logger.info("Bookmarking {}".format(status)) api.bookmark(self.app, self.user, status.id) def _unbookmark(): - logger.info("Unbookmarking {}".format(status)) api.unbookmark(self.app, self.user, status.id) def _done(loop): @@ -616,6 +755,12 @@ class TUI(urwid.Frame): return self.run_in_thread(_delete, done_callback=_done) + def copy_status(self, status): + # TODO: copy a better version of status content + # including URLs + copy_to_clipboard(self.screen, status.original.data["content"]) + self.footer.set_message(f"Status {status.original.id} copied") + # --- Overlay handling ----------------------------------------------------- default_overlay_options = dict( @@ -624,7 +769,7 @@ class TUI(urwid.Frame): ) def open_overlay(self, widget, options={}, title=""): - top_widget = urwid.LineBox(widget, title=title) + top_widget = RoundedLineBox(widget, title=title) bottom_widget = self.body _options = self.default_overlay_options.copy() @@ -640,12 +785,46 @@ class TUI(urwid.Frame): def close_overlay(self): self.body = self.overlay.bottom_w self.overlay = None + if self.timeline: + self.timeline.refresh_status_details() + + def refresh_timeline(self): + # No point in refreshing the bookmarks timeline + # and we don't have a good way to refresh a + # list timeline yet (no reference to list ID kept) + if (not self.timeline + or self.timeline.name == 'bookmarks' + or self.timeline.name.startswith("\N{clipboard}")): + return + + if self.timeline.name.startswith("#"): + self.timeline_generator = api.tag_timeline_generator( + self.app, self.user, self.timeline.name[1:], limit=40) + elif self.timeline.name.startswith("\N{clipboard}"): + self.timeline_generator = api.tag_timeline_generator( + self.app, self.user, self.timeline.name[1:], limit=40) + else: + if self.timeline.name.endswith("public"): + self.timeline_generator = api.public_timeline_generator( + self.app, self.user, local=self.timeline.name.startswith("local"), limit=40) + elif self.timeline.name == "notifications": + self.timeline_generator = api.notification_timeline_generator( + self.app, self.user, limit=40) + elif self.timeline.name == "conversations": + self.timeline_generator = api.conversation_timeline_generator( + self.app, self.user, limit=40) + else: + # default to home timeline + self.timeline_generator = api.home_timeline_generator( + self.app, self.user, limit=40) + + self.async_load_timeline(is_initial=True, timeline_name=self.timeline.name) # --- Keys ----------------------------------------------------------------- def unhandled_input(self, key): # TODO: this should not be in unhandled input - if key in ('e', 'E'): + if key in ('x', 'X'): if self.exception: self.show_exception(self.exception) @@ -653,15 +832,13 @@ class TUI(urwid.Frame): if not self.overlay: self.show_goto_menu() - elif key in ('h', 'H'): + elif key == '?': if not self.overlay: self.show_help() elif key == ',': if not self.overlay: - self.timeline_generator = api.home_timeline_generator( - self.app, self.user, limit=40) - self.async_load_timeline(is_initial=True, timeline_name=self.timeline.name) + self.refresh_timeline() elif key == 'esc': if self.overlay: diff --git a/toot/tui/compose.py b/toot/tui/compose.py index 4b31c0f..a931fa3 100644 --- a/toot/tui/compose.py +++ b/toot/tui/compose.py @@ -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 diff --git a/toot/tui/constants.py b/toot/tui/constants.py index e866e34..9a1a2e5 100644 --- a/toot/tui/constants.py +++ b/toot/tui/constants.py @@ -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 = [ diff --git a/toot/tui/entities.py b/toot/tui/entities.py index a30bcb6..642e953 100644 --- a/toot/tui/entities.py +++ b/toot/tui/entities.py @@ -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) diff --git a/toot/tui/overlays.py b/toot/tui/overlays.py index 1fa09da..3b8645d 100644 --- a/toot/tui/overlays.py +++ b/toot/tui/overlays.py @@ -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) diff --git a/toot/tui/poll.py b/toot/tui/poll.py new file mode 100644 index 0000000..661dc5b --- /dev/null +++ b/toot/tui/poll.py @@ -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)) diff --git a/toot/tui/richtext/__init__.py b/toot/tui/richtext/__init__.py new file mode 100644 index 0000000..07e31c8 --- /dev/null +++ b/toot/tui/richtext/__init__.py @@ -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)) diff --git a/toot/tui/richtext/richtext.py b/toot/tui/richtext/richtext.py new file mode 100644 index 0000000..71897c4 --- /dev/null +++ b/toot/tui/richtext/richtext.py @@ -0,0 +1,452 @@ +import re +import urwid +import unicodedata + +from bs4.element import NavigableString, Tag +from toot.tui.constants import PALETTE +from toot.utils import parse_html, urlencode_url +from typing import List, Tuple +from urwid.util import decompose_tagmarkup +from urwidgets import Hyperlink, TextEmbed + + +STYLE_NAMES = [p[0] for p in PALETTE] + +# NOTE: update this list if Mastodon starts supporting more block tags +BLOCK_TAGS = ["p", "pre", "li", "blockquote", "h1", "h2", "h3", "h4", "h5", "h6"] + + +def html_to_widgets(html, recovery_attempt=False) -> List[urwid.Widget]: + """Convert html to urwid widgets""" + widgets: List[urwid.Widget] = [] + html = unicodedata.normalize("NFKC", html) + soup = parse_html(html) + + first_tag = True + for e in soup.body or soup: + if isinstance(e, NavigableString): + if first_tag and not recovery_attempt: + # if our first "tag" is a navigable string + # the HTML is out of spec, doesn't start with a tag, + # we see this in content from Pixelfed servers. + # attempt a fix by wrapping the HTML with

+ return html_to_widgets(f"

{html}

", recovery_attempt=True) + else: + continue + else: + name = e.name + # if our HTML starts with a tag, but not a block tag + # the HTML is out of spec. Attempt a fix by wrapping the + # HTML with

+ if (first_tag and not recovery_attempt and name not in BLOCK_TAGS): + return html_to_widgets(f"

{html}

", recovery_attempt=True) + + markup = render(name, e) + first_tag = False + + if not isinstance(markup, urwid.Widget): + # plaintext, so create a padded text widget + txt = text_to_widget("", markup) + markup = urwid.Padding( + txt, + align="left", + width=("relative", 100), + min_width=None, + ) + widgets.append(markup) + # separate top level widgets with a blank line + widgets.append(urwid.Divider(" ")) + return widgets[:-1] # but suppress the last blank line + + +def url_to_widget(url: str): + widget = len(url), urwid.Filler(Hyperlink(url, "link", url)) + return TextEmbed(widget) + + +def inline_tag_to_text(tag) -> Tuple: + """Convert html tag to plain text with tag as attributes recursively""" + markups = process_inline_tag_children(tag) + if not markups: + return (tag.name, "") + return (tag.name, markups) + + +def process_inline_tag_children(tag) -> List: + """Recursively retrieve all children + and convert to a list of markup text""" + markups = [] + for child in tag.children: + if isinstance(child, Tag): + markup = render(child.name, child) + markups.append(markup) + else: + markups.append(child) + return markups + + +URL_PATTERN = re.compile(r"(^.+)\x03(.+$)") + + +def text_to_widget(attr, markup) -> urwid.Widget: + markup_list = [] + for run in markup: + if isinstance(run, tuple): + txt, attr_list = decompose_tagmarkup(run) + # find anchor titles with an ETX separator followed by href + match = URL_PATTERN.match(txt) + if match: + label, url = match.groups() + anchor_attr = get_best_anchor_attr(attr_list) + markup_list.append(( + len(label), + urwid.Filler(Hyperlink(url, anchor_attr, label)), + )) + else: + markup_list.append(run) + else: + markup_list.append(run) + + return TextEmbed(markup_list) + + +def process_block_tag_children(tag) -> List[urwid.Widget]: + """Recursively retrieve all children + and convert to a list of widgets + any inline tags containing text will be + converted to Text widgets""" + + pre_widget_markups = [] + post_widget_markups = [] + child_widgets = [] + found_nested_widget = False + + for child in tag.children: + if isinstance(child, Tag): + # child is a nested tag; process using custom method + # or default to inline_tag_to_text + result = render(child.name, child) + if isinstance(result, urwid.Widget): + found_nested_widget = True + child_widgets.append(result) + else: + if not found_nested_widget: + pre_widget_markups.append(result) + else: + post_widget_markups.append(result) + else: + # child is text; append to the appropriate markup list + if not found_nested_widget: + pre_widget_markups.append(child) + else: + post_widget_markups.append(child) + + widget_list = [] + if len(pre_widget_markups): + widget_list.append(text_to_widget(tag.name, pre_widget_markups)) + + if len(child_widgets): + widget_list += child_widgets + + if len(post_widget_markups): + widget_list.append(text_to_widget(tag.name, post_widget_markups)) + + return widget_list + + +def get_urwid_attr_name(tag) -> str: + """Get the class name and translate to a + name suitable for use as an urwid + text attribute name""" + + if "class" in tag.attrs: + clss = tag.attrs["class"] + if len(clss) > 0: + style_name = "class_" + "_".join(clss) + # return the class name, only if we + # find it as a defined palette name + if style_name in STYLE_NAMES: + return style_name + + # fallback to returning the tag name + return tag.name + + +def basic_block_tag_handler(tag) -> urwid.Widget: + """default for block tags that need no special treatment""" + return urwid.Pile(process_block_tag_children(tag)) + + +def get_best_anchor_attr(attrib_list) -> str: + if not attrib_list: + return "" + flat_al = list(flatten(attrib_list)) + + for a in flat_al[0]: + # ref: https://docs.joinmastodon.org/spec/activitypub/ + # these are the class names (translated to attrib names) + # that we can support for display + + try: + if a[0] in ["class_hashtag", "class_mention_hashtag", "class_mention"]: + return a[0] + except KeyError: + continue + + return "a" + + +def render(attr: str, content: str): + if attr in ["a"]: + return render_anchor(content) + + if attr in ["blockquote"]: + return render_blockquote(content) + + if attr in ["br"]: + return render_br(content) + + if attr in ["em"]: + return render_em(content) + + if attr in ["ol"]: + return render_ol(content) + + if attr in ["pre"]: + return render_pre(content) + + if attr in ["span"]: + return render_span(content) + + if attr in ["b", "strong"]: + return render_strong(content) + + if attr in ["ul"]: + return render_ul(content) + + # Glitch-soc and Pleroma allow

...

in content + # Mastodon (PR #23913) does not; header tags are converted to

+ if attr in ["p", "div", "li", "h1", "h2", "h3", "h4", "h5", "h6"]: + return basic_block_tag_handler(content) + + # Fall back to inline_tag_to_text handler + return inline_tag_to_text(content) + + +def render_anchor(tag) -> Tuple: + """anchor tag handler""" + + markups = process_inline_tag_children(tag) + if not markups: + return (tag.name, "") + + href = tag.attrs["href"] + title, attrib_list = decompose_tagmarkup(markups) + if not attrib_list: + attrib_list = [tag] + if href: + # urlencode the path and query portions of the URL + href = urlencode_url(href) + # use ASCII ETX (end of record) as a + # delimiter between the title and the HREF + title += f"\x03{href}" + + attr = get_best_anchor_attr(attrib_list) + + if attr == "a": + # didn't find an attribute to use + # in the child markup, so let's + # try the anchor tag's own attributes + + attr = get_urwid_attr_name(tag) + + # hashtag anchors have a class of "mention hashtag" + # or "hashtag" + # we'll return style "class_mention_hashtag" + # or "class_hashtag" + # in that case; see corresponding palette entry + # in constants.py controlling hashtag highlighting + + return (attr, title) + + +def render_blockquote(tag) -> urwid.Widget: + widget_list = process_block_tag_children(tag) + blockquote_widget = urwid.LineBox( + urwid.Padding( + urwid.Pile(widget_list), + align="left", + width=("relative", 100), + min_width=None, + left=1, + right=1, + ), + tlcorner="", + tline="", + lline="│", + trcorner="", + blcorner="", + rline="", + bline="", + brcorner="", + ) + return urwid.Pile([urwid.AttrMap(blockquote_widget, "blockquote")]) + + +def render_br(tag) -> Tuple: + return ("br", "\n") + + +def render_em(tag) -> Tuple: + # to simplify the number of palette entries + # translate EM to I (italic) + markups = process_inline_tag_children(tag) + if not markups: + return ("i", "") + + # special case processing for bold and italic + for parent in tag.parents: + if parent.name == "b" or parent.name == "strong": + return ("bi", markups) + + return ("i", markups) + + +def render_ol(tag) -> urwid.Widget: + """ordered list tag handler""" + + widgets = [] + list_item_num = 1 + increment = -1 if tag.has_attr("reversed") else 1 + + # get ol start= attribute if present + if tag.has_attr("start") and len(tag.attrs["start"]) > 0: + try: + list_item_num = int(tag.attrs["start"]) + except ValueError: + pass + + for li in tag.find_all("li", recursive=False): + markup = render("li", li) + + # li value= attribute will change the item number + # it also overrides any ol start= attribute + + if li.has_attr("value") and len(li.attrs["value"]) > 0: + try: + list_item_num = int(li.attrs["value"]) + except ValueError: + pass + + if not isinstance(markup, urwid.Widget): + txt = text_to_widget("li", [str(list_item_num), ". ", markup]) + # 1. foo, 2. bar, etc. + widgets.append(txt) + else: + txt = text_to_widget("li", [str(list_item_num), ". "]) + columns = urwid.Columns( + [txt, ("weight", 9999, markup)], dividechars=1, min_width=3 + ) + widgets.append(columns) + + list_item_num += increment + + return urwid.Pile(widgets) + + +def render_pre(tag) -> urwid.Widget: + #
 tag spec says that text should not wrap,
+    # but horizontal screen space is at a premium
+    # and we have no horizontal scroll bar, so allow
+    # wrapping.
+
+    widget_list = [urwid.Divider(" ")]
+    widget_list += process_block_tag_children(tag)
+
+    pre_widget = urwid.Padding(
+        urwid.Pile(widget_list),
+        align="left",
+        width=("relative", 100),
+        min_width=None,
+        left=1,
+        right=1,
+    )
+    return urwid.Pile([urwid.AttrMap(pre_widget, "pre")])
+
+
+def render_span(tag) -> Tuple:
+    markups = process_inline_tag_children(tag)
+
+    if not markups:
+        return (tag.name, "")
+
+    # span inherits its parent's class definition
+    # unless it has a specific class definition
+    # of its own
+
+    if "class" in tag.attrs:
+        # uncomment the following code to hide all HTML marked
+        # invisible (generally, the http:// prefix of URLs)
+        # could be a user preference, it's only advisable if
+        # the terminal supports OCS 8 hyperlinks (and that's not
+        # automatically detectable)
+
+        # if "invisible" in tag.attrs["class"]:
+        #     return (tag.name, "")
+
+        style_name = get_urwid_attr_name(tag)
+
+        if style_name != "span":
+            # unique class name matches an entry in our palette
+            return (style_name, markups)
+
+    if tag.parent:
+        return (get_urwid_attr_name(tag.parent), markups)
+    else:
+        # fallback
+        return ("span", markups)
+
+
+def render_strong(tag) -> Tuple:
+    # to simplify the number of palette entries
+    # translate STRONG to B (bold)
+    markups = process_inline_tag_children(tag)
+    if not markups:
+        return ("b", "")
+
+    # special case processing for bold and italic
+    for parent in tag.parents:
+        if parent.name == "i" or parent.name == "em":
+            return ("bi", markups)
+
+    return ("b", markups)
+
+
+def render_ul(tag) -> urwid.Widget:
+    """unordered list tag handler"""
+
+    widgets = []
+
+    for li in tag.find_all("li", recursive=False):
+        markup = render("li", li)
+
+        if not isinstance(markup, urwid.Widget):
+            txt = text_to_widget("li", ["\N{bullet} ", markup])
+            # * foo, * bar, etc.
+            widgets.append(txt)
+        else:
+            txt = text_to_widget("li", ["\N{bullet} "])
+            columns = urwid.Columns(
+                [txt, ("weight", 9999, markup)], dividechars=1, min_width=3
+            )
+            widgets.append(columns)
+
+    return urwid.Pile(widgets)
+
+
+def flatten(data):
+    if isinstance(data, tuple):
+        for x in data:
+            yield from flatten(x)
+    else:
+        yield data
diff --git a/toot/tui/scroll.py b/toot/tui/scroll.py
index fa2c3bb..e2bf9f0 100644
--- a/toot/tui/scroll.py
+++ b/toot/tui/scroll.py
@@ -1,8 +1,3 @@
-# scroll.py
-#
-# Copied from the stig project by rndusr@github
-# https://github.com/rndusr/stig
-#
 # 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
@@ -36,12 +31,12 @@ class Scrollable(urwid.WidgetDecoration):
     def selectable(self):
         return True
 
-    def __init__(self, widget):
+    def __init__(self, widget, force_forward_keypress = False):
         """Box widget that makes a fixed or flow widget vertically scrollable
 
         TODO: Focusable widgets are handled, including switching focus, but
         possibly not intuitively, depending on the arrangement of widgets.  When
-        switching focus to a widget that is outside of the visible part of the
+        switching focus to a widget that is ouside of the visible part of the
         original widget, the canvas scrolls up/down to the focused widget.  It
         would be better to scroll until the next focusable widget is in sight
         first.  But for that to work we must somehow obtain a list of focusable
@@ -54,6 +49,7 @@ class Scrollable(urwid.WidgetDecoration):
         self._forward_keypress = None
         self._old_cursor_coords = None
         self._rows_max_cached = 0
+        self.force_forward_keypress = force_forward_keypress
         self.__super.__init__(widget)
 
     def render(self, size, focus=False):
@@ -111,6 +107,51 @@ class Scrollable(urwid.WidgetDecoration):
             if canv_full.cursor is not None:
                 # Full canvas contains the cursor, but scrolled out of view
                 self._forward_keypress = False
+
+                # Reset cursor position on page/up down scrolling
+                try:
+                    if hasattr(ow, "automove_cursor_on_scroll") and ow.automove_cursor_on_scroll:
+                        pwi = 0
+                        ch = 0
+                        last_hidden = False
+                        first_visible = False
+                        for w,o in ow.contents:
+                            wcanv = w.render((maxcol,))
+                            wh = wcanv.rows()
+                            if wh:
+                                ch += wh
+
+                            if not last_hidden and ch >= self._trim_top:
+                                last_hidden = True
+
+                            elif last_hidden:
+                                if not first_visible:
+                                    first_visible = True
+
+                                if w.selectable():
+                                    ow.focus_item = pwi
+
+                                    st = None
+                                    nf = ow.get_focus()
+                                    if hasattr(nf, "key_timeout"):
+                                        st = nf
+                                    elif hasattr(nf, "original_widget"):
+                                        no = nf.original_widget
+                                        if hasattr(no, "original_widget"):
+                                            st = no.original_widget
+                                        else:
+                                            if hasattr(no, "key_timeout"):
+                                                st = no
+
+                                    if st and hasattr(st, "key_timeout") and hasattr(st, "keypress") and callable(st.keypress):
+                                        st.keypress(None, None)
+
+                                    break
+
+                            pwi += 1
+                except Exception as e:
+                    pass
+
             else:
                 # Original widget does not have a cursor, but may be selectable
 
@@ -132,7 +173,7 @@ class Scrollable(urwid.WidgetDecoration):
 
     def keypress(self, size, key):
         # Maybe offer key to original widget
-        if self._forward_keypress:
+        if self._forward_keypress or self.force_forward_keypress:
             ow = self._original_widget
             ow_size = self._get_original_widget_size(size)
 
@@ -216,7 +257,7 @@ class Scrollable(urwid.WidgetDecoration):
         # If the cursor was moved by the most recent keypress, adjust trim_top
         # so that the new cursor position is within the displayed canvas part.
         # But don't do this if the cursor is at the top/bottom edge so we can still scroll out
-        if self._old_cursor_coords is not None and self._old_cursor_coords != canv.cursor:
+        if self._old_cursor_coords is not None and self._old_cursor_coords != canv.cursor and canv.cursor != None:
             self._old_cursor_coords = None
             curscol, cursrow = canv.cursor
             if cursrow < self._trim_top:
@@ -227,10 +268,10 @@ class Scrollable(urwid.WidgetDecoration):
     def _get_original_widget_size(self, size):
         ow = self._original_widget
         sizing = ow.sizing()
-        if FIXED in sizing:
-            return ()
-        elif FLOW in sizing:
+        if FLOW in sizing:
             return (size[0],)
+        elif FIXED in sizing:
+            return ()
 
     def get_scrollpos(self, size=None, focus=False):
         """Current scrolling position
@@ -416,11 +457,14 @@ class ScrollBar(urwid.WidgetDecoration):
         if not handled and hasattr(ow, 'set_scrollpos'):
             if button == 4:    # scroll wheel up
                 pos = ow.get_scrollpos(ow_size)
-                ow.set_scrollpos(pos - 1)
+                newpos = pos - 1
+                if newpos < 0:
+                    newpos = 0
+                ow.set_scrollpos(newpos)
                 return True
             elif button == 5:  # scroll wheel down
                 pos = ow.get_scrollpos(ow_size)
                 ow.set_scrollpos(pos + 1)
                 return True
 
-        return False
\ No newline at end of file
+        return False
diff --git a/toot/tui/timeline.py b/toot/tui/timeline.py
index d4b9b74..b9311e7 100644
--- a/toot/tui/timeline.py
+++ b/toot/tui/timeline.py
@@ -1,17 +1,18 @@
 import logging
-import sys
 import urwid
 import webbrowser
 
-from typing import Optional
+from typing import List, Optional
 
-from .entities import Status
-from .scroll import Scrollable, ScrollBar
-from .utils import highlight_hashtags, parse_datetime, highlight_keys
-from .widgets import SelectableText, SelectableColumns
-from toot.utils import format_content
+from toot.tui import app
+from toot.tui.richtext import html_to_widgets, url_to_widget
+from toot.utils.datetime import parse_datetime, time_ago
 from toot.utils.language import language_name
-from toot.tui.utils import time_ago
+
+from toot.entities import Status
+from toot.tui.scroll import Scrollable, ScrollBar
+from toot.tui.utils import highlight_keys
+from toot.tui.widgets import SelectableText, SelectableColumns, RoundedLineBox
 
 logger = logging.getLogger("toot")
 
@@ -21,33 +22,25 @@ class Timeline(urwid.Columns):
     Displays a list of statuses to the left, and status details on the right.
     """
     signals = [
-        "close",         # Close thread
-        "compose",       # Compose a new toot
-        "delete",        # Delete own status
-        "favourite",     # Favourite status
-        "focus",         # Focus changed
-        "bookmark",      # Bookmark status
-        "media",         # Display media attachments
-        "menu",          # Show a context menu
-        "next",          # Fetch more statuses
-        "reblog",        # Reblog status
-        "reply",         # Compose a reply to a status
-        "source",        # Show status source
-        "links",         # Show status links
-        "thread",        # Show thread for status
-        "translate",     # Translate status
-        "save",          # Save current timeline
-        "zoom",          # Open status in scrollable popup window
-        "clear-screen",  # Clear the screen (used internally)
+        "close",  # Close thread
+        "focus",  # Focus changed
+        "next",   # Fetch more statuses
+        "save",   # Save current timeline
     ]
 
-    def __init__(self, name, statuses, can_translate, followed_tags=[], focus=0, is_thread=False):
+    def __init__(
+        self,
+        tui: "app.TUI",
+        name: str,
+        statuses: List[Status],
+        focus: int = 0,
+        is_thread: bool = False
+    ):
+        self.tui = tui
         self.name = name
         self.is_thread = is_thread
         self.statuses = statuses
-        self.can_translate = can_translate
         self.status_list = self.build_status_list(statuses, focus=focus)
-        self.followed_tags = followed_tags
 
         try:
             focused_status = statuses[focus]
@@ -59,16 +52,17 @@ class Timeline(urwid.Columns):
 
         super().__init__([
             ("weight", 40, self.status_list),
-            ("weight", 0, urwid.AttrWrap(urwid.SolidFill("│"), "blue_selected")),
+            ("weight", 0, urwid.AttrWrap(urwid.SolidFill("│"), "columns_divider")),
             ("weight", 60, status_widget),
         ])
 
     def wrap_status_details(self, status_details: "StatusDetails") -> urwid.Widget:
-        """Wrap StatusDetails widget with a scollbar and footer."""
+        """Wrap StatusDetails widget with a scrollbar and footer."""
+        self.status_detail_scrollable = Scrollable(urwid.Padding(status_details, right=1))
         return urwid.Padding(
             urwid.Frame(
                 body=ScrollBar(
-                    Scrollable(urwid.Padding(status_details, right=1)),
+                    self.status_detail_scrollable,
                     thumb_char="\u2588",
                     trough_char="\u2591",
                 ),
@@ -85,38 +79,45 @@ class Timeline(urwid.Columns):
         return urwid.ListBox(walker)
 
     def build_list_item(self, status):
-        item = StatusListItem(status)
+        item = StatusListItem(status, self.tui.options.relative_datetimes)
         urwid.connect_signal(item, "click", lambda *args:
-            self._emit("menu", status))
+            self.tui.show_context_menu(status))
         return urwid.AttrMap(item, None, focus_map={
-            "blue": "green_selected",
-            "green": "green_selected",
-            "yellow": "green_selected",
-            "cyan": "green_selected",
-            "red": "green_selected",
-            None: "green_selected",
+            "status_list_account": "status_list_selected",
+            "status_list_timestamp": "status_list_selected",
+            "highlight": "status_list_selected",
+            "dim": "status_list_selected",
+            None: "status_list_selected",
         })
 
     def get_option_text(self, status: Optional[Status]) -> Optional[urwid.Text]:
         if not status:
             return None
 
+        poll = status.original.data.get("poll")
+        show_media = status.original.data["media_attachments"] and self.tui.options.media_viewer
+
         options = [
+            "[A]ccount" if not status.is_mine else "",
             "[B]oost",
             "[D]elete" if status.is_mine else "",
+            "[E]dit" if status.is_mine else "",
             "B[o]okmark",
             "[F]avourite",
             "[V]iew",
             "[T]hread" if not self.is_thread else "",
-            "[L]inks",
+            "L[i]nks",
+            "[M]edia" if show_media else "",
             "[R]eply",
+            "[P]oll" if poll and not poll["expired"] else "",
             "So[u]rce",
             "[Z]oom",
-            "Tra[n]slate" if self.can_translate else "",
-            "[H]elp",
+            "Tra[n]slate" if self.tui.can_translate else "",
+            "Cop[y]",
+            "Help([?])",
         ]
         options = "\n" + " ".join(o for o in options if o)
-        options = highlight_keys(options, "white_bold", "cyan")
+        options = highlight_keys(options, "shortcut_highlight", "shortcut")
         return urwid.Text(options)
 
     def get_focused_status(self):
@@ -146,7 +147,9 @@ class Timeline(urwid.Columns):
     def refresh_status_details(self):
         """Redraws the details of the focused status."""
         status = self.get_focused_status()
+        pos = self.status_detail_scrollable.get_scrollpos()
         self.draw_status_details(status)
+        self.status_detail_scrollable.set_scrollpos(pos)
 
     def draw_status_details(self, status):
         self.status_details = StatusDetails(self, status)
@@ -169,24 +172,35 @@ class Timeline(urwid.Columns):
             if index >= count:
                 self._emit("next")
 
+        if key in ("a", "A"):
+            account_id = status.original.data["account"]["id"]
+            self.tui.show_account(account_id)
+            return
+
         if key in ("b", "B"):
-            self._emit("reblog", status)
+            self.tui.async_toggle_reblog(self, status)
             return
 
         if key in ("c", "C"):
-            self._emit("compose")
+            self.tui.show_compose()
             return
 
         if key in ("d", "D"):
-            self._emit("delete", status)
+            if status.is_mine:
+                self.tui.show_delete_confirmation(status)
+            return
+
+        if key in ("e", "E"):
+            if status.is_mine:
+                self.tui.async_edit(status)
             return
 
         if key in ("f", "F"):
-            self._emit("favourite", status)
+            self.tui.async_toggle_favourite(self, status)
             return
 
         if key in ("m", "M"):
-            self._emit("media", status)
+            self.tui.show_media(status)
             return
 
         if key in ("q", "Q"):
@@ -198,7 +212,7 @@ class Timeline(urwid.Columns):
             return
 
         if key in ("r", "R"):
-            self._emit("reply", status)
+            self.tui.show_compose(status)
             return
 
         if key in ("s", "S"):
@@ -207,39 +221,49 @@ class Timeline(urwid.Columns):
             return
 
         if key in ("o", "O"):
-            self._emit("bookmark", status)
+            self.tui.async_toggle_bookmark(self, status)
             return
 
-        if key in ("l", "L"):
-            self._emit("links", status)
+        if key in ("i", "I"):
+            self.tui.show_links(status)
             return
 
         if key in ("n", "N"):
-            if self.can_translate:
-                self._emit("translate", status)
+            if self.tui.can_translate:
+                self.tui.async_translate(self, status)
             return
 
         if key in ("t", "T"):
-            self._emit("thread", status)
+            self.tui.show_thread(status)
             return
 
         if key in ("u", "U"):
-            self._emit("source", status)
+            self.tui.show_status_source(status)
             return
 
         if key in ("v", "V"):
             if status.original.url:
                 webbrowser.open(status.original.url)
                 # force a screen refresh; necessary with console browsers
-                self._emit("clear-screen")
+                self.tui.clear_screen()
             return
 
-        if key in ("p", "P"):
+        if key in ("e", "E"):
             self._emit("save", status)
             return
 
         if key in ("z", "Z"):
-            self._emit("zoom", self.status_details)
+            self.tui.show_status_zoom(self.status_details)
+            return
+
+        if key in ("p", "P"):
+            poll = status.original.data.get("poll")
+            if poll and not poll["expired"]:
+                self.tui.show_poll(status)
+            return
+
+        if key in ("y", "Y"):
+            self.tui.copy_status(status)
             return
 
         return super().keypress(size, key)
@@ -294,7 +318,8 @@ class Timeline(urwid.Columns):
 class StatusDetails(urwid.Pile):
     def __init__(self, timeline: Timeline, status: Optional[Status]):
         self.status = status
-        self.followed_tags = timeline.followed_tags
+        self.followed_accounts = timeline.tui.followed_accounts
+        self.options = timeline.tui.options
 
         reblogged_by = status.author if status and status.reblog else None
         widget_list = list(self.content_generator(status.original, reblogged_by)
@@ -304,13 +329,14 @@ class StatusDetails(urwid.Pile):
     def content_generator(self, status, reblogged_by):
         if reblogged_by:
             text = "♺ {} boosted".format(reblogged_by.display_name or reblogged_by.username)
-            yield ("pack", urwid.Text(("gray", text)))
-            yield ("pack", urwid.AttrMap(urwid.Divider("-"), "gray"))
+            yield ("pack", urwid.Text(("dim", text)))
+            yield ("pack", urwid.AttrMap(urwid.Divider("-"), "dim"))
 
         if status.author.display_name:
-            yield ("pack", urwid.Text(("green", status.author.display_name)))
+            yield ("pack", urwid.Text(("bold", status.author.display_name)))
 
-        yield ("pack", urwid.Text(("yellow", status.author.account)))
+        account_color = "highlight" if status.author.account in self.followed_accounts else "account"
+        yield ("pack", urwid.Text((account_color, status.author.account)))
         yield ("pack", urwid.Divider())
 
         if status.data["spoiler_text"]:
@@ -318,23 +344,28 @@ class StatusDetails(urwid.Pile):
             yield ("pack", urwid.Divider())
 
         # Show content warning
-        if status.data["spoiler_text"] and not status.show_sensitive:
+        if status.data["spoiler_text"] and not status.show_sensitive and not self.options.always_show_sensitive:
             yield ("pack", urwid.Text(("content_warning", "Marked as sensitive. Press S to view.")))
         else:
-            content = status.translation if status.show_translation else status.data["content"]
-            for line in format_content(content):
-                yield ("pack", urwid.Text(highlight_hashtags(line, self.followed_tags)))
+            if status.data["spoiler_text"]:
+                yield ("pack", urwid.Text(("content_warning", "Marked as sensitive.")))
+
+            content = status.original.translation if status.original.show_translation else status.data["content"]
+            widgetlist = html_to_widgets(content)
+
+            for line in widgetlist:
+                yield (line)
 
             media = status.data["media_attachments"]
             if media:
                 for m in media:
-                    yield ("pack", urwid.AttrMap(urwid.Divider("-"), "gray"))
+                    yield ("pack", urwid.AttrMap(urwid.Divider("-"), "dim"))
                     yield ("pack", urwid.Text([("bold", "Media attachment"), " (", m["type"], ")"]))
                     if m["description"]:
                         yield ("pack", urwid.Text(m["description"]))
-                    yield ("pack", urwid.Text(("link", m["url"])))
+                    yield ("pack", url_to_widget(m["url"]))
 
-            poll = status.data.get("poll")
+            poll = status.original.data.get("poll")
             if poll:
                 yield ("pack", urwid.Divider())
                 yield ("pack", self.build_linebox(self.poll_generator(poll)))
@@ -347,33 +378,35 @@ class StatusDetails(urwid.Pile):
         application = status.data.get("application") or {}
         application = application.get("name")
 
-        yield ("pack", urwid.AttrWrap(urwid.Divider("-"), "gray"))
+        yield ("pack", urwid.AttrWrap(urwid.Divider("-"), "dim"))
 
         translated_from = (
-            language_name(status.translated_from)
-            if status.show_translation and status.translated_from
+            language_name(status.original.translated_from)
+            if status.original.show_translation and status.original.translated_from
             else None
         )
 
         visibility_colors = {
-            "public": "gray",
-            "unlisted": "white",
-            "private": "cyan",
-            "direct": "yellow"
+            "public": "visibility_public",
+            "unlisted": "visibility_unlisted",
+            "private": "visibility_private",
+            "direct": "visibility_direct"
         }
 
         visibility = status.visibility.title()
-        visibility_color = visibility_colors.get(status.visibility, "gray")
+        visibility_color = visibility_colors.get(status.visibility, "dim")
 
         yield ("pack", urwid.Text([
-            ("blue", f"{status.created_at.strftime('%Y-%m-%d %H:%M')} "),
-            ("red" if status.bookmarked else "gray", "🠷 "),
-            ("gray", f"⤶ {status.data['replies_count']} "),
-            ("yellow" if status.reblogged else "gray", f"♺ {status.data['reblogs_count']} "),
-            ("yellow" if status.favourited else "gray", f"★ {status.data['favourites_count']}"),
+            ("status_detail_timestamp", f"{status.created_at.strftime('%Y-%m-%d %H:%M')} "),
+            ("status_detail_timestamp",
+             f"(edited {status.edited_at.strftime('%Y-%m-%d %H:%M')}) " if status.edited_at else ""),
+            ("status_detail_bookmarked" if status.bookmarked else "dim", "b "),
+            ("dim", f"⤶ {status.data['replies_count']} "),
+            ("highlight" if status.reblogged else "dim", f"♺ {status.data['reblogs_count']} "),
+            ("highlight" if status.favourited else "dim", f"★ {status.data['favourites_count']}"),
             (visibility_color, f" · {visibility}"),
-            ("yellow", f" · Translated from {translated_from} " if translated_from else ""),
-            ("gray", f" · {application}" if application else ""),
+            ("highlight", f" · Translated from {translated_from} " if translated_from else ""),
+            ("dim", f" · {application}" if application else ""),
         ]))
 
         # Push things to bottom
@@ -382,17 +415,17 @@ class StatusDetails(urwid.Pile):
     def build_linebox(self, contents):
         contents = urwid.Pile(list(contents))
         contents = urwid.Padding(contents, left=1, right=1)
-        return urwid.LineBox(contents)
+        return RoundedLineBox(contents)
 
     def card_generator(self, card):
-        yield urwid.Text(("green", card["title"].strip()))
+        yield urwid.Text(("card_title", card["title"].strip()))
         if card.get("author_name"):
-            yield urwid.Text(["by ", ("yellow", card["author_name"].strip())])
+            yield urwid.Text(["by ", ("card_author", card["author_name"].strip())])
         yield urwid.Text("")
         if card["description"]:
             yield urwid.Text(card["description"].strip())
             yield urwid.Text("")
-        yield urwid.Text(("link", card["url"]))
+        yield url_to_widget(card["url"])
 
     def poll_generator(self, poll):
         for idx, option in enumerate(poll["options"]):
@@ -416,36 +449,36 @@ class StatusDetails(urwid.Pile):
             expires_at = parse_datetime(poll["expires_at"]).strftime("%Y-%m-%d %H:%M")
             status += " · Closes on {}".format(expires_at)
 
-        yield urwid.Text(("gray", status))
+        yield urwid.Text(("dim", status))
 
 
 class StatusListItem(SelectableColumns):
-    def __init__(self, status):
-        edited = status.data["edited_at"]
+    def __init__(self, status, relative_datetimes):
+        edited_at = status.original.edited_at
 
         # TODO: hacky implementation to avoid creating conflicts for existing
-        # pull reuqests, refactor when merged.
+        # pull requests, refactor when merged.
         created_at = (
             time_ago(status.created_at).ljust(3, " ")
-            if "--relative-datetimes" in sys.argv
+            if relative_datetimes
             else status.created_at.strftime("%Y-%m-%d %H:%M")
         )
 
-        edited_flag = "*" if edited else " "
-        favourited = ("yellow", "★") if status.original.favourited else " "
-        reblogged = ("yellow", "♺") if status.original.reblogged else " "
-        is_reblog = ("cyan", "♺") if status.reblog else " "
-        is_reply = ("cyan", "⤶") if status.original.in_reply_to else " "
+        edited_flag = "*" if edited_at else " "
+        favourited = ("highlight", "★") if status.original.favourited else " "
+        reblogged = ("highlight", "♺") if status.original.reblogged else " "
+        is_reblog = ("dim", "♺") if status.reblog else " "
+        is_reply = ("dim", "⤶ ") if status.original.in_reply_to else "  "
 
         return super().__init__([
-            ("pack", SelectableText(("blue", created_at), wrap="clip")),
-            ("pack", urwid.Text(("blue", edited_flag))),
+            ("pack", SelectableText(("status_list_timestamp", created_at), wrap="clip")),
+            ("pack", urwid.Text(("status_list_timestamp", edited_flag))),
             ("pack", urwid.Text(" ")),
             ("pack", urwid.Text(favourited)),
             ("pack", urwid.Text(" ")),
             ("pack", urwid.Text(reblogged)),
             ("pack", urwid.Text(" ")),
-            urwid.Text(("green", status.original.account), wrap="clip"),
+            urwid.Text(("status_list_account", status.original.account), wrap="clip"),
             ("pack", urwid.Text(is_reply)),
             ("pack", urwid.Text(is_reblog)),
             ("pack", urwid.Text(" ")),
diff --git a/toot/tui/utils.py b/toot/tui/utils.py
index e2855c4..5b94624 100644
--- a/toot/tui/utils.py
+++ b/toot/tui/utils.py
@@ -1,58 +1,12 @@
-from html.parser import HTMLParser
-import math
-import os
+import base64
 import re
-import shutil
-import subprocess
+import urwid
 
-from datetime import datetime, timezone
+from functools import reduce
+from html.parser import HTMLParser
+from typing import List
 
 HASHTAG_PATTERN = re.compile(r'(? datetime:
-    now = datetime.now().astimezone()
-    delta = now.timestamp() - value.timestamp()
-
-    if (delta < 1):
-        return "now"
-
-    if (delta < 8 * DAY):
-        if (delta < MINUTE):
-            return f"{math.floor(delta / SECOND)}".rjust(2, " ") + "s"
-        if (delta < HOUR):
-            return f"{math.floor(delta / MINUTE)}".rjust(2, " ") + "m"
-        if (delta < DAY):
-            return f"{math.floor(delta / HOUR)}".rjust(2, " ") + "h"
-        return f"{math.floor(delta / DAY)}".rjust(2, " ") + "d"
-
-    if (delta < 53 * WEEK):  # not exactly correct but good enough as a boundary
-        return f"{math.floor(delta / WEEK)}".rjust(2, " ") + "w"
-
-    return ">1y"
 
 
 def highlight_keys(text, high_attr, low_attr=""):
@@ -79,48 +33,19 @@ def highlight_keys(text, high_attr, low_attr=""):
     return list(_gen())
 
 
-def highlight_hashtags(line, followed_tags, attr="hashtag", followed_attr="followed_hashtag"):
+def highlight_hashtags(line):
     hline = []
 
     for p in re.split(HASHTAG_PATTERN, line):
         if p.startswith("#"):
-            if p[1:].lower() in (t.lower() for t in followed_tags):
-                hline.append((followed_attr, p))
-            else:
-                hline.append((attr, p))
+            hline.append(("hashtag", p))
         else:
             hline.append(p)
 
     return hline
 
 
-def show_media(paths):
-    """
-    Attempt to open an image viewer to show given media files.
-
-    FIXME: This is not very thought out, but works for me.
-    Once settings are implemented, add an option for the user to configure their
-    prefered media viewer.
-    """
-    viewer = None
-    potential_viewers = [
-        "feh",
-        "eog",
-        "display"
-    ]
-    for v in potential_viewers:
-        viewer = shutil.which(v)
-        if viewer:
-            break
-
-    if not viewer:
-        raise Exception("Cannot find an image viewer")
-
-    subprocess.run([viewer] + paths)
-
-
 class LinkParser(HTMLParser):
-
     def reset(self):
         super().reset()
         self.links = []
@@ -144,3 +69,43 @@ def parse_content_links(content):
     parser = LinkParser()
     parser.feed(content)
     return parser.links[:]
+
+
+def copy_to_clipboard(screen: urwid.raw_display.Screen, text: str):
+    """ copy text to clipboard using OSC 52
+    This escape sequence is documented
+    here https://iterm2.com/documentation-escape-codes.html
+    It has wide support - XTerm, Windows Terminal,
+    Kitty, iTerm2, others. Some terminals may require a setting to be
+    enabled in order to use OSC 52 clipboard functions.
+    """
+
+    text_bytes = text.encode("utf-8")
+    b64_bytes = base64.b64encode(text_bytes)
+    b64_text = b64_bytes.decode("utf-8")
+
+    screen.write(f"\033]52;c;{b64_text}\a")
+    screen.flush()
+
+
+def get_max_toot_chars(instance, default=500):
+    # Mastodon
+    # https://docs.joinmastodon.org/entities/Instance/#max_characters
+    max_toot_chars = deep_get(instance, ["configuration", "statuses", "max_characters"])
+    if isinstance(max_toot_chars, int):
+        return max_toot_chars
+
+    # Pleroma
+    max_toot_chars = instance.get("max_toot_chars")
+    if isinstance(max_toot_chars, int):
+        return max_toot_chars
+
+    return default
+
+
+def deep_get(adict: dict, path: List[str], default=None):
+    return reduce(
+        lambda d, key: d.get(key, default) if isinstance(d, dict) else default,
+        path,
+        adict
+    )
diff --git a/toot/tui/widgets.py b/toot/tui/widgets.py
index a311d52..db7bf9e 100644
--- a/toot/tui/widgets.py
+++ b/toot/tui/widgets.py
@@ -1,4 +1,5 @@
 import urwid
+from wcwidth import wcswidth
 
 
 class Clickable:
@@ -40,9 +41,67 @@ class Button(urwid.AttrWrap):
     """Styled button."""
     def __init__(self, *args, **kwargs):
         button = urwid.Button(*args, **kwargs)
-        padding = urwid.Padding(button, width=len(args[0]) + 4)
+        padding = urwid.Padding(button, width=wcswidth(args[0]) + 4)
         return super().__init__(padding, "button", "button_focused")
 
     def set_label(self, *args, **kwargs):
         self.original_widget.original_widget.set_label(*args, **kwargs)
-        self.original_widget.width = len(args[0]) + 4
+        self.original_widget.width = wcswidth(args[0]) + 4
+
+
+class CheckBox(urwid.AttrWrap):
+    """Styled checkbox."""
+    def __init__(self, *args, **kwargs):
+        self.button = urwid.CheckBox(*args, **kwargs)
+        padding = urwid.Padding(self.button, width=len(args[0]) + 4)
+        return super().__init__(padding, "button", "button_focused")
+
+    def get_state(self):
+        """Return the state of the checkbox."""
+        return self.button._state
+
+
+class RadioButton(urwid.AttrWrap):
+    """Styled radiobutton."""
+    def __init__(self, *args, **kwargs):
+        button = urwid.RadioButton(*args, **kwargs)
+        padding = urwid.Padding(button, width=len(args[1]) + 4)
+        return super().__init__(padding, "button", "button_focused")
+
+
+class ModalBox(urwid.Frame):
+    def __init__(self, message):
+        text = urwid.Text(message)
+        filler = urwid.Filler(text, valign='top', top=1, bottom=1)
+        padding = urwid.Padding(filler, left=1, right=1)
+        return super().__init__(padding)
+
+
+class RoundedLineBox(urwid.LineBox):
+    """LineBox that defaults to rounded corners."""
+    def __init__(self,
+                 original_widget,
+                 title="",
+                 title_align="center",
+                 title_attr=None,
+                 tlcorner="\u256d",
+                 tline="─",
+                 lline="│",
+                 trcorner="\u256e",
+                 blcorner="\u2570",
+                 rline="│",
+                 bline="─",
+                 brcorner="\u256f",
+                 ) -> None:
+        return super().__init__(original_widget,
+                            title,
+                            title_align,
+                            title_attr,
+                            tlcorner,
+                            tline,
+                            lline,
+                            trcorner,
+                            blcorner,
+                            rline,
+                            bline,
+                            brcorner)
diff --git a/toot/typing_compat.py b/toot/typing_compat.py
new file mode 100644
index 0000000..0c6fe5d
--- /dev/null
+++ b/toot/typing_compat.py
@@ -0,0 +1,147 @@
+# Taken from https://github.com/rossmacarthur/typing-compat/
+# TODO: Remove once the minimum python version is increased to 3.8
+#
+# Licensed under the MIT license
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+#
+# flake8: noqa
+
+import collections
+import typing
+
+
+__all__ = ['get_args', 'get_origin']
+__title__ = 'typing-compat'
+__version__ = '0.1.0'
+__url__ = 'https://github.com/rossmacarthur/typing-compat'
+__author__ = 'Ross MacArthur'
+__author_email__ = 'ross@macarthur.io'
+__description__ = 'Python typing compatibility library'
+
+
+try:
+    # Python >=3.8 should have these functions already
+    from typing import get_args as _get_args      # novermin
+    from typing import get_origin as _get_origin  # novermin
+except ImportError:
+    if hasattr(typing, '_GenericAlias'):  # Python 3.7
+
+        def _get_origin(tp):
+            """Copied from the Python 3.8 typing module"""
+            if isinstance(tp, typing._GenericAlias):
+                return tp.__origin__
+            if tp is typing.Generic:
+                return typing.Generic
+            return None
+
+        def _get_args(tp):
+            """Copied from the Python 3.8 typing module"""
+            if isinstance(tp, typing._GenericAlias):
+                res = tp.__args__
+                if (
+                    get_origin(tp) is collections.abc.Callable
+                    and res[0] is not Ellipsis
+                ):
+                    res = (list(res[:-1]), res[-1])
+                return res
+            return ()
+
+    else:  # Python <3.7
+
+        def _resolve_via_mro(tp):
+            if hasattr(tp, '__mro__'):
+                for t in tp.__mro__:
+                    if t.__module__ in ('builtins', '__builtin__') and t is not object:
+                        return t
+            return tp
+
+        def _get_origin(tp):
+            """Emulate the behaviour of Python 3.8 typing module"""
+            if isinstance(tp, typing._ClassVar):
+                return typing.ClassVar
+            elif isinstance(tp, typing._Union):
+                return typing.Union
+            elif isinstance(tp, typing.GenericMeta):
+                if hasattr(tp, '_gorg'):
+                    return _resolve_via_mro(tp._gorg)
+                else:
+                    while tp.__origin__ is not None:
+                        tp = tp.__origin__
+                    return _resolve_via_mro(tp)
+            elif hasattr(typing, '_Literal') and isinstance(tp, typing._Literal):  # novermin
+                return typing.Literal  # novermin
+
+        def _normalize_arg(args):
+            if isinstance(args, tuple) and len(args) > 1:
+                base, rest = args[0], tuple(_normalize_arg(arg) for arg in args[1:])
+                if isinstance(base, typing.CallableMeta):
+                    return typing.Callable[list(rest[:-1]), rest[-1]]
+                elif isinstance(base, (typing.GenericMeta, typing._Union)):
+                    return base[rest]
+            return args
+
+        def _get_args(tp):
+            """Emulate the behaviour of Python 3.8 typing module"""
+            if isinstance(tp, typing._ClassVar):
+                return (tp.__type__,)
+            elif hasattr(tp, '_subs_tree'):
+                tree = tp._subs_tree()
+                if isinstance(tree, tuple) and len(tree) > 1:
+                    if isinstance(tree[0], typing.CallableMeta) and len(tree) == 2:
+                        return ([], _normalize_arg(tree[1]))
+                    return tuple(_normalize_arg(arg) for arg in tree[1:])
+            return ()
+
+
+def get_origin(tp):
+    """
+    Get the unsubscripted version of a type.
+
+    This supports generic types, Callable, Tuple, Union, Literal, Final and
+    ClassVar. Returns None for unsupported types.
+
+    Examples:
+
+        get_origin(Literal[42]) is Literal
+        get_origin(int) is None
+        get_origin(ClassVar[int]) is ClassVar
+        get_origin(Generic) is Generic
+        get_origin(Generic[T]) is Generic
+        get_origin(Union[T, int]) is Union
+        get_origin(List[Tuple[T, T]][int]) == list
+    """
+    return _get_origin(tp)
+
+
+def get_args(tp):
+    """
+    Get type arguments with all substitutions performed.
+
+    For unions, basic simplifications used by Union constructor are performed.
+
+    Examples:
+
+        get_args(Dict[str, int]) == (str, int)
+        get_args(int) == ()
+        get_args(Union[int, Union[T, int], str][int]) == (int, str)
+        get_args(Union[int, Tuple[T, int]][str]) == (int, Tuple[str, int])
+        get_args(Callable[[], T][int]) == ([], int)
+    """
+    return _get_args(tp)
diff --git a/toot/utils/__init__.py b/toot/utils/__init__.py
index c76e65f..7bc5c77 100644
--- a/toot/utils/__init__.py
+++ b/toot/utils/__init__.py
@@ -7,8 +7,12 @@ import unicodedata
 import warnings
 
 from bs4 import BeautifulSoup
+from typing import Any, Dict, List
+
+import click
 
 from toot.exceptions import ConsoleError
+from urllib.parse import urlparse, urlencode, quote, unquote
 
 
 def str_bool(b):
@@ -16,20 +20,27 @@ def str_bool(b):
     return "true" if b else "false"
 
 
-def get_text(html):
-    """Converts html to text, strips all tags."""
+def str_bool_nullable(b):
+    """Similar to str_bool, but leave None as None"""
+    return None if b is None else str_bool(b)
 
+
+def parse_html(html: str) -> BeautifulSoup:
     # Ignore warnings made by BeautifulSoup, if passed something that looks like
     # a file (e.g. a dot which matches current dict), it will warn that the file
     # should be opened instead of passing a filename.
     with warnings.catch_warnings():
         warnings.simplefilter("ignore")
-        text = BeautifulSoup(html.replace(''', "'"), "html.parser").get_text()
-
-    return unicodedata.normalize('NFKC', text)
+        return BeautifulSoup(html.replace("'", "'"), "html.parser")
 
 
-def parse_html(html):
+def get_text(html):
+    """Converts html to text, strips all tags."""
+    text = parse_html(html).get_text()
+    return unicodedata.normalize("NFKC", text)
+
+
+def html_to_paragraphs(html: str) -> List[List[str]]:
     """Attempt to convert html to plain text while keeping line breaks.
     Returns a list of paragraphs, each being a list of lines.
     """
@@ -48,7 +59,7 @@ def format_content(content):
     Returns a generator yielding lines of content.
     """
 
-    paragraphs = parse_html(content)
+    paragraphs = html_to_paragraphs(content)
 
     first = True
 
@@ -100,17 +111,56 @@ Everything below it will be ignored.
 """
 
 
-def editor_input(editor, initial_text):
+def editor_input(editor: str, initial_text: str) -> str:
     """Lets user input text using an editor."""
+    tmp_path = _tmp_status_path()
     initial_text = (initial_text or "") + EDITOR_INPUT_INSTRUCTIONS
 
-    with tempfile.NamedTemporaryFile(suffix='.toot') as f:
-        f.write(initial_text.encode())
-        f.flush()
+    if not _use_existing_tmp_file(tmp_path):
+        with open(tmp_path, "w") as f:
+            f.write(initial_text)
+            f.flush()
 
-        subprocess.run([editor, f.name])
+    subprocess.run([editor, tmp_path])
 
-        f.seek(0)
-        text = f.read().decode()
+    with open(tmp_path) as f:
+        return f.read().split(EDITOR_DIVIDER)[0].strip()
 
-    return text.split(EDITOR_DIVIDER)[0].strip()
+
+def delete_tmp_status_file() -> None:
+    try:
+        os.unlink(_tmp_status_path())
+    except FileNotFoundError:
+        pass
+
+
+def _tmp_status_path() -> str:
+    tmp_dir = tempfile.gettempdir()
+    return f"{tmp_dir}/.status.toot"
+
+
+def _use_existing_tmp_file(tmp_path: str) -> bool:
+    if os.path.exists(tmp_path):
+        click.echo(f"Found draft status at: {tmp_path}")
+
+        choice = click.Choice(["O", "D"], case_sensitive=False)
+        char = click.prompt("Open or Delete?", type=choice, default="O")
+        return char == "O"
+
+    return False
+
+
+def drop_empty_values(data: Dict[Any, Any]) -> Dict[Any, Any]:
+    """Remove keys whose values are null"""
+    return {k: v for k, v in data.items() if v is not None}
+
+
+def urlencode_url(url: str) -> str:
+    parsed_url = urlparse(url)
+
+    # unencode before encoding, to prevent double-urlencoding
+    encoded_path = quote(unquote(parsed_url.path), safe="-._~()'!*:@,;+&=/")
+    encoded_query = urlencode({k: quote(unquote(v), safe="-._~()'!*:@,;?/") for k, v in parsed_url.params})
+    encoded_url = parsed_url._replace(path=encoded_path, params=encoded_query).geturl()
+
+    return encoded_url
diff --git a/toot/utils/datetime.py b/toot/utils/datetime.py
new file mode 100644
index 0000000..2a214a0
--- /dev/null
+++ b/toot/utils/datetime.py
@@ -0,0 +1,45 @@
+import math
+import os
+
+from datetime import datetime, timezone
+
+
+def parse_datetime(value):
+    """Returns an aware datetime in local timezone"""
+    dttm = datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%f%z")
+
+    # When running tests return datetime in UTC so that tests don't depend on
+    # the local timezone
+    if "PYTEST_CURRENT_TEST" in os.environ:
+        return dttm.astimezone(timezone.utc)
+
+    return dttm.astimezone()
+
+
+SECOND = 1
+MINUTE = SECOND * 60
+HOUR = MINUTE * 60
+DAY = HOUR * 24
+WEEK = DAY * 7
+
+
+def time_ago(value: datetime) -> str:
+    now = datetime.now().astimezone()
+    delta = now.timestamp() - value.timestamp()
+
+    if delta < 1:
+        return "now"
+
+    if delta < 8 * DAY:
+        if delta < MINUTE:
+            return f"{math.floor(delta / SECOND)}".rjust(2, " ") + "s"
+        if delta < HOUR:
+            return f"{math.floor(delta / MINUTE)}".rjust(2, " ") + "m"
+        if delta < DAY:
+            return f"{math.floor(delta / HOUR)}".rjust(2, " ") + "h"
+        return f"{math.floor(delta / DAY)}".rjust(2, " ") + "d"
+
+    if delta < 53 * WEEK:  # not exactly correct but good enough as a boundary
+        return f"{math.floor(delta / WEEK)}".rjust(2, " ") + "w"
+
+    return ">1y"
diff --git a/toot/wcstring.py b/toot/wcstring.py
index cc4bee1..31fe2c4 100644
--- a/toot/wcstring.py
+++ b/toot/wcstring.py
@@ -3,11 +3,12 @@ Utilities for dealing with string containing wide characters.
 """
 
 import re
+from typing import Generator, List
 
 from wcwidth import wcwidth, wcswidth
 
 
-def _wc_hard_wrap(line, length):
+def _wc_hard_wrap(line: str, length: int) -> Generator[str, None, None]:
     """
     Wrap text to length characters, breaking when target length is reached,
     taking into account character width.
@@ -20,7 +21,7 @@ def _wc_hard_wrap(line, length):
         char_len = wcwidth(char)
         if chars_len + char_len > length:
             yield "".join(chars)
-            chars = []
+            chars: List[str] = []
             chars_len = 0
 
         chars.append(char)
@@ -30,7 +31,7 @@ def _wc_hard_wrap(line, length):
         yield "".join(chars)
 
 
-def wc_wrap(text, length):
+def wc_wrap(text: str, length: int) -> Generator[str, None, None]:
     """
     Wrap text to given length, breaking on whitespace and taking into account
     character width.
@@ -38,7 +39,7 @@ def wc_wrap(text, length):
     Meant for use on a single line or paragraph. Will destroy spacing between
     words and paragraphs and any indentation.
     """
-    line_words = []
+    line_words: List[str] = []
     line_len = 0
 
     words = re.split(r"\s+", text.strip())
@@ -66,7 +67,7 @@ def wc_wrap(text, length):
             yield from _wc_hard_wrap(line, length)
 
 
-def trunc(text, length):
+def trunc(text: str, length: int) -> str:
     """
     Truncates text to given length, taking into account wide characters.
 
@@ -98,7 +99,7 @@ def trunc(text, length):
     return text[:-n].strip() + '…'
 
 
-def pad(text, length):
+def pad(text: str, length: int) -> str:
     """Pads text to given length, taking into account wide characters."""
     text_length = wcswidth(text)
 
@@ -108,7 +109,7 @@ def pad(text, length):
     return text
 
 
-def fit_text(text, length):
+def fit_text(text: str, length: int) -> str:
     """Makes text fit the given length by padding or truncating it."""
     text_length = wcswidth(text)