├── tests ├── __init__.py ├── integration │ ├── __init__.py │ ├── test_polls.py │ ├── conftest.py │ ├── test_update_account.py │ ├── test_lists.py │ ├── test_tags.py │ ├── test_read.py │ ├── test_status.py │ └── test_auth.py ├── assets │ ├── test1.png │ ├── test2.png │ ├── test3.png │ ├── test4.png │ └── small.webm ├── test_version.py ├── utils.py ├── README.md ├── tui │ └── test_rich_text.py └── test_config.py ├── MANIFEST.in ├── pytest.ini ├── toot ├── __main__.py ├── tui │ ├── richtext │ │ └── __init__.py │ ├── __init__.py │ ├── NOTES.md │ ├── entities.py │ ├── constants.py │ ├── widgets.py │ ├── poll.py │ ├── images.py │ ├── utils.py │ └── compose.py ├── urwidgets │ ├── README.md │ └── __init__.py ├── exceptions.py ├── utils │ ├── datetime.py │ ├── language.py │ └── __init__.py ├── __init__.py ├── cli │ ├── polls.py │ ├── follow_requests.py │ ├── tui.py │ ├── validators.py │ ├── tags.py │ ├── diag.py │ ├── statuses.py │ ├── read.py │ ├── auth.py │ ├── __init__.py │ └── timelines.py ├── settings.py ├── cache.py ├── logging.py ├── auth.py ├── wcstring.py ├── config.py └── http.py ├── .coveragerc ├── trumpet.png ├── docs ├── trumpet.png ├── images │ ├── auth.png │ ├── trumpet.png │ ├── tui_list.png │ └── tui_compose.png ├── SUMMARY.md ├── environment_variables.md ├── shell_completion.md ├── documentation.md ├── advanced.md ├── release.md ├── tui.md ├── installation.md ├── introduction.md ├── testing.md ├── settings.md ├── usage.md └── contributing.md ├── .vermin ├── .flake8 ├── book.toml ├── .gitignore ├── book.css ├── .github └── workflows │ └── test.yml ├── scripts ├── generate_changelog └── tag_version ├── Makefile ├── pyproject.toml ├── README.rst └── CONTRIBUTING.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include tests * -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = tests 3 | -------------------------------------------------------------------------------- /toot/__main__.py: -------------------------------------------------------------------------------- 1 | from toot.cli import cli 2 | 3 | cli() 4 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source=./toot 3 | command_line=-m pytest 4 | -------------------------------------------------------------------------------- /trumpet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ihabunek/toot/HEAD/trumpet.png -------------------------------------------------------------------------------- /docs/trumpet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ihabunek/toot/HEAD/docs/trumpet.png -------------------------------------------------------------------------------- /.vermin: -------------------------------------------------------------------------------- 1 | [vermin] 2 | only_show_violations = yes 3 | show_tips = no 4 | targets = 3.9- -------------------------------------------------------------------------------- /docs/images/auth.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ihabunek/toot/HEAD/docs/images/auth.png -------------------------------------------------------------------------------- /tests/assets/test1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ihabunek/toot/HEAD/tests/assets/test1.png -------------------------------------------------------------------------------- /tests/assets/test2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ihabunek/toot/HEAD/tests/assets/test2.png -------------------------------------------------------------------------------- /tests/assets/test3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ihabunek/toot/HEAD/tests/assets/test3.png -------------------------------------------------------------------------------- /tests/assets/test4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ihabunek/toot/HEAD/tests/assets/test4.png -------------------------------------------------------------------------------- /docs/images/trumpet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ihabunek/toot/HEAD/docs/images/trumpet.png -------------------------------------------------------------------------------- /docs/images/tui_list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ihabunek/toot/HEAD/docs/images/tui_list.png -------------------------------------------------------------------------------- /tests/assets/small.webm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ihabunek/toot/HEAD/tests/assets/small.webm -------------------------------------------------------------------------------- /docs/images/tui_compose.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ihabunek/toot/HEAD/docs/images/tui_compose.png -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude=build,tests,tmp,venv,_env,toot/tui/scroll.py 3 | ignore=E128,W503,W504 4 | max-line-length=120 5 | -------------------------------------------------------------------------------- /toot/tui/richtext/__init__.py: -------------------------------------------------------------------------------- 1 | from .richtext import html_to_widgets, url_to_widget 2 | 3 | __all__ = ( 4 | "html_to_widgets", 5 | "url_to_widget", 6 | ) 7 | -------------------------------------------------------------------------------- /book.toml: -------------------------------------------------------------------------------- 1 | [book] 2 | authors = ["Ivan Habunek"] 3 | language = "en" 4 | multilingual = false 5 | src = "docs" 6 | title = "toot" 7 | 8 | [output.html] 9 | additional-css = ["book.css"] 10 | 11 | [preprocessor.toc] 12 | command = "mdbook-toc" 13 | renderer = ["html"] 14 | -------------------------------------------------------------------------------- /tests/test_version.py: -------------------------------------------------------------------------------- 1 | import toot 2 | from pkg_resources import get_distribution 3 | 4 | 5 | def test_version(): 6 | """Version specified in __version__ should be the same as the one 7 | specified in setup.py.""" 8 | assert toot.__version__ == get_distribution('toot').version 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info/ 2 | *.pyc 3 | .pypirc 4 | /.cache/ 5 | /.coverage 6 | /.env 7 | /.envrc 8 | /.pytest_cache/ 9 | /book 10 | /build/ 11 | /bundle/ 12 | /dist/ 13 | /htmlcov/ 14 | /pyrightconfig.json 15 | /tmp/ 16 | /toot-*.pyz 17 | /toot-*.tar.gz 18 | /venv/ 19 | debug.log 20 | /uv.lock -------------------------------------------------------------------------------- /toot/urwidgets/README.md: -------------------------------------------------------------------------------- 1 | urwidgets vendored from: 2 | https://github.com/AnonymouX47/urwidgets 3 | 4 | Licensed under the MIT license: 5 | https://github.com/AnonymouX47/urwidgets/blob/main/LICENSE 6 | 7 | This was done to be able to upgrade to urwid version 3, since urwidgets are 8 | limited to urwid < 3. 9 | -------------------------------------------------------------------------------- /toot/tui/__init__.py: -------------------------------------------------------------------------------- 1 | from urwid.command_map import command_map 2 | from urwid.command_map import CURSOR_UP, CURSOR_DOWN, CURSOR_LEFT, CURSOR_RIGHT 3 | 4 | # Add movement using h/j/k/l to default command map 5 | command_map._command.update({ 6 | 'k': CURSOR_UP, 7 | 'j': CURSOR_DOWN, 8 | 'h': CURSOR_LEFT, 9 | 'l': CURSOR_RIGHT, 10 | }) 11 | -------------------------------------------------------------------------------- /book.css: -------------------------------------------------------------------------------- 1 | /* Overrides for the docs theme */ 2 | table { width: 100% } 3 | table th { text-align: left } 4 | code { white-space: pre } 5 | h2, h3 { margin-top: 2.5rem; } 6 | h4, h5 { margin-top: 2rem; } 7 | 8 | td.code { 9 | font-family: "Source Code Pro", Consolas, "Ubuntu Mono", Menlo, "DejaVu Sans Mono", monospace, monospace !important; 10 | font-size: 0.875em; 11 | width: 20%; 12 | white-space: nowrap; 13 | } 14 | -------------------------------------------------------------------------------- /toot/exceptions.py: -------------------------------------------------------------------------------- 1 | from click import ClickException 2 | 3 | 4 | class ApiError(ClickException): 5 | """Raised when an API request fails for whatever reason.""" 6 | 7 | 8 | class NotFoundError(ApiError): 9 | """Raised when an API requests returns a 404.""" 10 | 11 | 12 | class AuthenticationError(ApiError): 13 | """Raised when login fails.""" 14 | 15 | 16 | class ConsoleError(ClickException): 17 | """Raised when an error occurs which needs to be show to the user.""" 18 | -------------------------------------------------------------------------------- /docs/SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | 3 | [Introduction](introduction.md) 4 | 5 | - [Installation](installation.md) 6 | - [Usage](usage.md) 7 | - [Advanced](advanced.md) 8 | - [Settings](settings.md) 9 | - [Shell completion](shell_completion.md) 10 | - [Environment variables](environment_variables.md) 11 | - [TUI](tui.md) 12 | - [Contributing](contributing.md) 13 | - [Documentation](documentation.md) 14 | - [Release procedure](release.md) 15 | - [Changelog](changelog.md) 16 | 17 | [License](license.md) 18 | -------------------------------------------------------------------------------- /docs/environment_variables.md: -------------------------------------------------------------------------------- 1 | # Environment variables 2 | 3 | > Introduced in toot v0.40.0 4 | 5 | Toot allows setting defaults for parameters via environment variables. 6 | 7 | Environment variables should be named `TOOT__`. 8 | 9 | ### Examples 10 | 11 | Command with option | Environment variable 12 | ------------------- | -------------------- 13 | `toot --color` | `TOOT_COLOR=true` 14 | `toot --no-color` | `TOOT_COLOR=false` 15 | `toot post --editor vim` | `TOOT_POST_EDITOR=vim` 16 | `toot post --visibility unlisted` | `TOOT_POST_VISIBILITY=unlisted` 17 | `toot tui --media-viewer feh` | `TOOT_TUI_MEDIA_VIEWER=feh` 18 | 19 | Note that these can also be set via the [settings file](./settings.html). 20 | -------------------------------------------------------------------------------- /docs/shell_completion.md: -------------------------------------------------------------------------------- 1 | # Shell completion 2 | 3 | > Introduced in toot 0.40.0 4 | 5 | Toot uses [Click shell completion](https://click.palletsprojects.com/en/8.1.x/shell-completion/) which works on Bash, Fish and Zsh. 6 | 7 | To enable completion, toot must be [installed](./installation.html) as a command and available by ivoking `toot`. Then follow the instructions for your shell. 8 | 9 | **Bash** 10 | 11 | Add to `~/.bashrc`: 12 | 13 | ``` 14 | eval "$(_TOOT_COMPLETE=bash_source toot)" 15 | ``` 16 | 17 | **Fish** 18 | 19 | Add to `~/.config/fish/completions/toot.fish`: 20 | 21 | ``` 22 | _TOOT_COMPLETE=fish_source toot | source 23 | ``` 24 | 25 | **Zsh** 26 | 27 | Add to `~/.zshrc`: 28 | 29 | ``` 30 | eval "$(_TOOT_COMPLETE=zsh_source toot)" 31 | ``` 32 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Helpers for testing. 3 | """ 4 | 5 | import time 6 | from typing import Callable, TypeVar 7 | 8 | 9 | T = TypeVar("T") 10 | 11 | 12 | def run_with_retries(fn: Callable[..., T]) -> T: 13 | """ 14 | Run the the given function repeatedly until it finishes without raising an 15 | AssertionError. Sleep a bit between attempts. If the function doesn't 16 | succeed in the given number of tries raises the AssertionError. Used for 17 | tests which should eventually succeed. 18 | """ 19 | 20 | # Wait upto 6 seconds with incrementally longer sleeps 21 | 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] 22 | 23 | for delay in delays: 24 | try: 25 | return fn() 26 | except AssertionError: 27 | time.sleep(delay) 28 | 29 | return fn() 30 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: [push, pull_request] 4 | jobs: 5 | test: 6 | runs-on: ubuntu-22.04 7 | strategy: 8 | matrix: 9 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 10 | env: 11 | UV_NO_MANAGED_PYTHON: "true" 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Set up Python ${{ matrix.python-version }} 15 | uses: actions/setup-python@v5 16 | with: 17 | python-version: ${{ matrix.python-version }} 18 | - name: Install the latest version of uv 19 | uses: astral-sh/setup-uv@v6 20 | - name: Install dependencies 21 | run: | 22 | uv sync 23 | - name: Run tests 24 | run: | 25 | uv run pytest 26 | - name: Validate minimum required version 27 | run: | 28 | uv run vermin --no-tips toot 29 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | Testing toot 2 | ============ 3 | 4 | This document is WIP. 5 | 6 | Mastodon 7 | -------- 8 | 9 | TODO 10 | 11 | Pleroma 12 | ------- 13 | 14 | TODO 15 | 16 | Akkoma 17 | ------ 18 | 19 | Install using the guide here: 20 | https://docs.akkoma.dev/stable/installation/docker_en/ 21 | 22 | Disable captcha and throttling by adding this to `config/prod.exs`: 23 | 24 | ```ex 25 | # Disable captcha for testing 26 | config :pleroma, Pleroma.Captcha, 27 | enabled: false 28 | 29 | # Disable rate limiting for testing 30 | config :pleroma, :rate_limit, 31 | authentication: nil, 32 | timeline: nil, 33 | search: nil, 34 | app_account_creation: nil, 35 | relations_actions: nil, 36 | relation_id_action: nil, 37 | statuses_actions: nil, 38 | status_id_action: nil, 39 | password_reset: nil, 40 | account_confirmation_resend: nil, 41 | ap_routes: nil 42 | ``` 43 | -------------------------------------------------------------------------------- /docs/documentation.md: -------------------------------------------------------------------------------- 1 | Documentation 2 | ============= 3 | 4 | Documentation is generated using [mdBook](https://rust-lang.github.io/mdBook/). 5 | 6 | Documentation is written in markdown and located in the `docs` directory. 7 | 8 | Additional plugins: 9 | 10 | - [mdbook-toc](https://github.com/badboy/mdbook-toc) 11 | 12 | Install prerequisites 13 | --------------------- 14 | 15 | You'll need a moderately recent version of Rust (1.60) at the time of writing. 16 | Check out [mdbook installation docs](https://rust-lang.github.io/mdBook/guide/installation.html) 17 | for details. 18 | 19 | Install by building from source: 20 | 21 | ``` 22 | cargo install mdbook mdbook-toc 23 | ``` 24 | 25 | Generate 26 | -------- 27 | 28 | HTML documentation is generated from sources by running: 29 | 30 | ``` 31 | mdbook build 32 | ``` 33 | 34 | To run a local server which will rebuild on change: 35 | 36 | ``` 37 | mdbook serve 38 | ``` 39 | -------------------------------------------------------------------------------- /toot/urwidgets/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | urWIDgets 3 | 4 | A collection of widgets for urwid (https://urwid.org) 5 | """ 6 | 7 | __all__ = ( 8 | "parse_text", 9 | "Hyperlink", 10 | "TextEmbed", 11 | # Type Aliases 12 | "Markup", 13 | "StringMarkup", 14 | "ListMarkup", 15 | "TupleMarkup", 16 | "NormalTupleMarkup", 17 | "DisplayAttribute", 18 | "WidgetTupleMarkup", 19 | "WidgetListMarkup", 20 | ) 21 | __author__ = "Toluwaleke Ogundipe" 22 | 23 | from .hyperlink import Hyperlink 24 | from .text_embed import ( 25 | DisplayAttribute, 26 | ListMarkup, 27 | Markup, 28 | NormalTupleMarkup, 29 | StringMarkup, 30 | TextEmbed, 31 | TupleMarkup, 32 | WidgetListMarkup, 33 | WidgetTupleMarkup, 34 | parse_text, 35 | ) 36 | 37 | version_info = (0, 3, 0, "dev") 38 | 39 | # Follows https://semver.org/spec/v2.0.0.html 40 | __version__ = ".".join(map(str, version_info[:3])) 41 | if version_info[3:]: 42 | __version__ += "-" + ".".join(map(str, version_info[3:])) 43 | -------------------------------------------------------------------------------- /scripts/generate_changelog: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Generates a more user-readable changelog from changelog.yaml. 5 | """ 6 | 7 | import textwrap 8 | import yaml 9 | 10 | with open("changelog.yaml", "r") as f: 11 | data = yaml.safe_load(f) 12 | 13 | print("Changelog") 14 | print("---------") 15 | print() 16 | print("") 17 | print() 18 | 19 | for version in data.keys(): 20 | date = data[version]["date"] 21 | changes = data[version]["changes"] 22 | print(f"**{version} ({date})**") 23 | print() 24 | 25 | if "description" in data[version]: 26 | print(textwrap.dedent(data[version]["description"])) 27 | print() 28 | 29 | for c in changes: 30 | lines = textwrap.wrap(c, 78) 31 | initial = True 32 | for line in lines: 33 | if initial: 34 | print("* " + line) 35 | initial = False 36 | else: 37 | print(" " + line) 38 | print() 39 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean publish test docs 2 | 3 | dist: 4 | python -m build 5 | 6 | publish : 7 | twine upload dist/*.tar.gz dist/*.whl 8 | 9 | test: 10 | pytest -v 11 | flake8 12 | vermin toot 13 | 14 | coverage: 15 | coverage erase 16 | coverage run 17 | coverage html --omit "toot/tui/*" 18 | coverage report 19 | 20 | clean : 21 | find . -name "*pyc" | xargs rm -rf $1 22 | rm -rf build dist book MANIFEST htmlcov bundle toot*.tar.gz toot*.pyz 23 | 24 | changelog: 25 | ./scripts/generate_changelog > CHANGELOG.md 26 | cp CHANGELOG.md docs/changelog.md 27 | 28 | docs: changelog 29 | mdbook build 30 | 31 | docs-serve: 32 | mdbook serve --port 8000 33 | 34 | docs-deploy: docs 35 | rsync --archive --compress --delete --stats book/ bezdomni:web/toot 36 | 37 | .PHONY: bundle 38 | bundle: 39 | mkdir bundle 40 | cp toot/__main__.py bundle 41 | pip install . --target=bundle 42 | rm -rf bundle/*.dist-info 43 | find bundle/ -type d -name "__pycache__" -exec rm -rf {} + 44 | python -m zipapp \ 45 | --python "/usr/bin/env python3" \ 46 | --output toot-`git describe`.pyz bundle \ 47 | --compress 48 | echo "Bundle created: toot-`git describe`.pyz" 49 | -------------------------------------------------------------------------------- /docs/advanced.md: -------------------------------------------------------------------------------- 1 | Advanced usage 2 | ============== 3 | 4 | Disabling HTTPS 5 | --------------- 6 | 7 | You may pass the `--disable-https` flag to use unencrypted HTTP instead of 8 | HTTPS for a given instance. This is inherently insecure and should be used only 9 | when connecting to local development instances. 10 | 11 | ```sh 12 | toot login --disable-https --instance localhost:8080 13 | ``` 14 | 15 | Using proxies 16 | ------------- 17 | 18 | You can configure proxies by setting the `HTTPS_PROXY` or `HTTP_PROXY` 19 | environment variables. This will cause all http(s) requests to be proxied 20 | through the specified server. 21 | 22 | For example: 23 | 24 | ```sh 25 | export HTTPS_PROXY="http://1.2.3.4:5678" 26 | toot login --instance mastodon.social 27 | ``` 28 | 29 | **NB:** This feature is provided by 30 | [requests](http://docs.python-requests.org/en/master/user/advanced/#proxies>) 31 | and setting the environment variable will affect other programs using this 32 | library. 33 | 34 | This environment can be set for a single call to toot by prefixing the command 35 | with the environment variable: 36 | 37 | ``` 38 | HTTPS_PROXY="http://1.2.3.4:5678" toot login --instance mastodon.social 39 | ``` 40 | -------------------------------------------------------------------------------- /docs/release.md: -------------------------------------------------------------------------------- 1 | Release procedure 2 | ================= 3 | 4 | This document is a checklist for creating a toot release. 5 | 6 | Currently the process is pretty manual and would benefit from automatization. 7 | 8 | Make docs and tag version 9 | ------------------------- 10 | 11 | * Update `changelog.yaml` with the release notes & date 12 | * Run `make docs` to generate changelog and update docs 13 | * Commit the changes 14 | * Run `./scripts/tag_version ` to tag a release in git 15 | * Run `git push --follow-tags` to upload changes and tag to GitHub 16 | 17 | Publishing to PyPI 18 | ------------------ 19 | 20 | * `make dist` to create source and wheel distributions 21 | * `make publish` to push them to PyPI 22 | 23 | GitHub release 24 | -------------- 25 | 26 | * [Create a release](https://github.com/ihabunek/toot/releases/) for the newly 27 | pushed tag, paste changelog since last tag in the description 28 | * Upload the assets generated in previous two steps to the release: 29 | * source dist (.zip and .tar.gz) 30 | * wheel distribution (.whl) 31 | 32 | TODO: this can be automated: https://developer.github.com/v3/repos/releases/ 33 | 34 | Update documentation 35 | -------------------- 36 | 37 | To regenerate HTML docs and deploy to toot.bezdomni.net: 38 | 39 | ``` 40 | make docs-deploy 41 | ``` 42 | -------------------------------------------------------------------------------- /toot/utils/datetime.py: -------------------------------------------------------------------------------- 1 | import math 2 | import os 3 | 4 | from datetime import datetime, timezone 5 | from dateutil.parser import parse 6 | 7 | 8 | def parse_datetime(value: str) -> datetime: 9 | """Returns an aware datetime in local timezone""" 10 | dttm = parse(value) 11 | 12 | # When running tests return datetime in UTC so that tests don't depend on 13 | # the local timezone 14 | if "PYTEST_CURRENT_TEST" in os.environ: 15 | return dttm.astimezone(timezone.utc) 16 | 17 | return dttm.astimezone() 18 | 19 | 20 | SECOND = 1 21 | MINUTE = SECOND * 60 22 | HOUR = MINUTE * 60 23 | DAY = HOUR * 24 24 | WEEK = DAY * 7 25 | 26 | 27 | def time_ago(value: datetime) -> str: 28 | now = datetime.now().astimezone() 29 | delta = now.timestamp() - value.timestamp() 30 | 31 | if delta < 1: 32 | return "now" 33 | 34 | if delta < 8 * DAY: 35 | if delta < MINUTE: 36 | return f"{math.floor(delta / SECOND)}".rjust(2, " ") + "s" 37 | if delta < HOUR: 38 | return f"{math.floor(delta / MINUTE)}".rjust(2, " ") + "m" 39 | if delta < DAY: 40 | return f"{math.floor(delta / HOUR)}".rjust(2, " ") + "h" 41 | return f"{math.floor(delta / DAY)}".rjust(2, " ") + "d" 42 | 43 | if delta < 53 * WEEK: # not exactly correct but good enough as a boundary 44 | return f"{math.floor(delta / WEEK)}".rjust(2, " ") + "w" 45 | 46 | return ">1y" 47 | -------------------------------------------------------------------------------- /tests/tui/test_rich_text.py: -------------------------------------------------------------------------------- 1 | from urwid import Divider, Filler, Pile 2 | from toot.tui.richtext import url_to_widget 3 | from toot.urwidgets import Hyperlink, TextEmbed 4 | 5 | from toot.tui.richtext.richtext import html_to_widgets 6 | 7 | 8 | def test_url_to_widget(): 9 | url = "http://foo.bar" 10 | embed_widget = url_to_widget(url) 11 | assert isinstance(embed_widget, TextEmbed) 12 | 13 | [(filler, length)] = embed_widget.embedded 14 | assert length == len(url) 15 | assert isinstance(filler, Filler) 16 | 17 | link_widget = filler.base_widget 18 | assert isinstance(link_widget, Hyperlink) 19 | 20 | assert link_widget.attrib == "link" 21 | assert link_widget.text == url 22 | assert link_widget.uri == url 23 | 24 | 25 | def test_html_to_widgets(): 26 | html = """ 27 |

foo

28 |

foo bar baz

29 | """.strip() 30 | 31 | [foo, divider, bar] = html_to_widgets(html) 32 | 33 | assert isinstance(foo, Pile) 34 | assert isinstance(divider, Divider) 35 | assert isinstance(bar, Pile) 36 | 37 | [(foo_embed, _)] = foo.contents 38 | assert foo_embed.embedded == [] 39 | assert foo_embed.attrib == [] 40 | assert foo_embed.text == "foo" 41 | 42 | [(bar_embed, _)] = bar.contents 43 | assert bar_embed.embedded == [] 44 | assert bar_embed.attrib == [(None, 4), ("b", 3), (None, 1), ("i", 3)] 45 | assert bar_embed.text == "foo bar baz" 46 | -------------------------------------------------------------------------------- /toot/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from os.path import join, expanduser 5 | from typing import NamedTuple 6 | from importlib import metadata 7 | 8 | 9 | try: 10 | __version__ = metadata.version("toot") 11 | except metadata.PackageNotFoundError: 12 | __version__ = "0.0.0" 13 | 14 | 15 | class App(NamedTuple): 16 | instance: str 17 | base_url: str 18 | client_id: str 19 | client_secret: str 20 | 21 | 22 | class User(NamedTuple): 23 | instance: str 24 | username: str 25 | access_token: str 26 | 27 | 28 | DEFAULT_INSTANCE = 'https://mastodon.social' 29 | 30 | CLIENT_NAME = 'toot - a Mastodon CLI client' 31 | CLIENT_WEBSITE = 'https://github.com/ihabunek/toot' 32 | 33 | TOOT_CONFIG_DIR_NAME = "toot" 34 | 35 | 36 | def get_config_dir(): 37 | """Returns the path to toot config directory""" 38 | 39 | # On Windows, store the config in roaming appdata 40 | if sys.platform == "win32" and "APPDATA" in os.environ: 41 | return join(os.getenv("APPDATA"), TOOT_CONFIG_DIR_NAME) 42 | 43 | # Respect XDG_CONFIG_HOME env variable if set 44 | # https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html 45 | if "XDG_CONFIG_HOME" in os.environ: 46 | config_home = expanduser(os.environ["XDG_CONFIG_HOME"]) 47 | return join(config_home, TOOT_CONFIG_DIR_NAME) 48 | 49 | # Default to ~/.config/toot/ 50 | return join(expanduser("~"), ".config", TOOT_CONFIG_DIR_NAME) 51 | -------------------------------------------------------------------------------- /toot/cli/polls.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | import click 4 | 5 | from toot import api 6 | from toot.cli import Context, cli, json_option, pass_context 7 | from toot.entities import Poll, from_response 8 | from toot.output import print_poll 9 | 10 | 11 | @cli.group() 12 | def polls(): 13 | """Show and vote on polls""" 14 | pass 15 | 16 | 17 | @polls.command() 18 | @click.argument("poll_id") 19 | @json_option 20 | @pass_context 21 | def show(ctx: Context, poll_id: str, json: bool): 22 | """Show a poll by ID""" 23 | response = api.get_poll(ctx.app, ctx.user, poll_id) 24 | 25 | if json: 26 | click.echo(response.text) 27 | else: 28 | poll = from_response(Poll, response) 29 | print_poll(poll) 30 | 31 | 32 | @polls.command() 33 | @click.argument("poll_id") 34 | @click.argument("choices", type=int, nargs=-1, required=True) 35 | @json_option 36 | @pass_context 37 | def vote(ctx: Context, poll_id: str, choices: Tuple[int], json: bool): 38 | """Vote on a poll 39 | 40 | CHOICES is one or more zero-indexed integers corresponding to the desired poll choices. 41 | 42 | e.g. to vote for the first and third options use: 43 | 44 | toot polls vote 0 2 45 | """ 46 | response = api.vote_poll(ctx.app, ctx.user, poll_id, choices) 47 | 48 | if json: 49 | click.echo(response.text) 50 | else: 51 | poll = from_response(Poll, response) 52 | click.echo("You voted!\n") 53 | print_poll(poll) 54 | -------------------------------------------------------------------------------- /docs/tui.md: -------------------------------------------------------------------------------- 1 | TUI 2 | === 3 | 4 | toot includes a 5 | [text-based user interface](https://en.wikipedia.org/wiki/Text-based_user_interface). 6 | Start it by running `toot tui`. 7 | 8 | ## Demo 9 | 10 | [![asciicast](https://asciinema.org/a/563459.svg)](https://asciinema.org/a/563459) 11 | 12 | ## Keyboard shortcuts 13 | 14 | Pressing `h` will bring up the help screen where all keyboard shortcuts are 15 | listed. 16 | 17 | **Navigation** 18 | 19 | * `Arrow keys` or `h/j/k/l` to move around and scroll content 20 | * `PageUp` and `PageDown` to scroll content 21 | * `Enter` or `Space` to activate buttons and menu options 22 | * `Esc` or `q` to go back, close overlays, such as menus and this help text 23 | 24 | **General** 25 | 26 | * `q` - quit toot 27 | * `g` - go to - switch timelines 28 | * `P` - pin/unpin current timeline 29 | * `,` - refresh current timeline 30 | * `?` - show this help 31 | 32 | **Status** 33 | 34 | These commands are applied to the currently focused status. 35 | 36 | * `a` - Show account 37 | * `b` - Boost/unboost status 38 | * `c` - Compose a new status 39 | * `d` - Delete status 40 | * `e` - Edit status 41 | * `f` - Favourite/unfavourite status 42 | * `i` - Show the status links 43 | * `m` - Show media in image viewer 44 | * `n` - Translate status if possible (toggle) 45 | * `o` - Bookmark/unbookmark status 46 | * `p` - Vote on a poll 47 | * `r` - Reply to status 48 | * `s` - Show text marked as sensitive 49 | * `t` - Show status thread (replies) 50 | * `u` - Show the status data in JSON as received from the server 51 | * `v` - Open status in default browser 52 | * `y` - Copy status to clipboard 53 | * `z` - Open status in scrollable popup window 54 | -------------------------------------------------------------------------------- /toot/cli/follow_requests.py: -------------------------------------------------------------------------------- 1 | import json as pyjson 2 | 3 | import click 4 | 5 | from toot import api 6 | from toot.cli import Context, cli, json_option, pass_context 7 | from toot.output import print_acct_list 8 | 9 | 10 | @cli.group() 11 | def follow_requests(): 12 | """Manage follow requests""" 13 | pass 14 | 15 | 16 | @follow_requests.command() 17 | @click.argument("account") 18 | @json_option 19 | @pass_context 20 | def accept(ctx: Context, account: str, json: bool): 21 | """Accept follow request from an account""" 22 | found_account = api.find_account(ctx.app, ctx.user, account) 23 | response = api.accept_follow_request(ctx.app, ctx.user, found_account["id"]) 24 | if json: 25 | click.echo(response.text) 26 | else: 27 | click.secho(f"✓ {account} is now following you", fg="green") 28 | 29 | 30 | @follow_requests.command() 31 | @click.argument("account") 32 | @json_option 33 | @pass_context 34 | def reject(ctx: Context, account: str, json: bool): 35 | """Reject follow request from an account""" 36 | found_account = api.find_account(ctx.app, ctx.user, account) 37 | response = api.reject_follow_request(ctx.app, ctx.user, found_account["id"]) 38 | if json: 39 | click.echo(response.text) 40 | else: 41 | click.secho(f"✓ follow request from {account} rejected", fg="green") 42 | 43 | 44 | @follow_requests.command() 45 | @json_option 46 | @pass_context 47 | def list(ctx: Context, json: bool): 48 | """List follow requests""" 49 | requests = api.list_follow_requests(ctx.app, ctx.user) 50 | if json: 51 | click.echo(pyjson.dumps(requests)) 52 | else: 53 | print_acct_list(requests) 54 | -------------------------------------------------------------------------------- /toot/settings.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | from os.path import exists, join 3 | from tomlkit import parse 4 | from toot import get_config_dir 5 | from typing import Optional, Type, TypeVar 6 | 7 | 8 | DISABLE_SETTINGS = False 9 | 10 | TOOT_SETTINGS_FILE_NAME = "settings.toml" 11 | 12 | 13 | def get_settings_path(): 14 | return join(get_config_dir(), TOOT_SETTINGS_FILE_NAME) 15 | 16 | 17 | def _load_settings() -> dict: 18 | # Used for testing without config file 19 | if DISABLE_SETTINGS: 20 | return {} 21 | 22 | path = get_settings_path() 23 | 24 | if not exists(path): 25 | return {} 26 | 27 | with open(path) as f: 28 | return parse(f.read()) 29 | 30 | 31 | @lru_cache(maxsize=None) 32 | def get_settings(): 33 | return _load_settings() 34 | 35 | 36 | T = TypeVar("T") 37 | 38 | 39 | def get_setting(key: str, type: Type[T], default: Optional[T] = None) -> Optional[T]: 40 | """ 41 | Get a setting value. The key should be a dot-separated string, 42 | e.g. "commands.post.editor" which will correspond to the "editor" setting 43 | inside the `[commands.post]` section. 44 | """ 45 | settings = get_settings() 46 | return _get_setting(settings, key.split("."), type, default) 47 | 48 | 49 | def _get_setting(dct, keys, type: Type, default=None): 50 | if len(keys) == 0: 51 | if isinstance(dct, type): 52 | return dct 53 | else: 54 | # TODO: warn? cast? both? 55 | return default 56 | 57 | key = keys[0] 58 | if isinstance(dct, dict) and key in dct: 59 | return _get_setting(dct[key], keys[1:], type, default) 60 | 61 | return default 62 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | ## Package repositories 5 | 6 | toot is packaged for various platforms. If possible use your OS's package manager to install toot. 7 | 8 |
9 | Packaging status 10 | 11 | Packaging status 12 | 13 |
14 | 15 | ## Homebrew 16 | 17 | For Mac users, toot is available [in homebrew](https://formulae.brew.sh/formula/toot#default). 18 | 19 | brew install toot 20 | 21 | ## Using pipx 22 | 23 | pipx installs packages from PyPI into isolated environments. It is the 24 | recommended installation method if there is no OS package or the package is 25 | outdated. 26 | 27 | Firstly, install pipx following their [installation instructions](https://pipx.pypa.io/stable/installation/). 28 | 29 | Install toot: 30 | 31 | pipx install toot 32 | 33 | Install with optional image support: 34 | 35 | pipx install "toot[images]" 36 | 37 | Upgrade to latest version: 38 | 39 | pipx upgrade toot 40 | 41 | ## From source 42 | 43 | You can get the latest source distribution [from Github](https://github.com/ihabunek/toot/releases/latest/). 44 | 45 | Clone the project and install into a virtual environment. 46 | 47 | ``` 48 | git clone git@github.com:ihabunek/toot.git 49 | cd toot 50 | python3 -m venv .venv 51 | source .venv/bin/activate 52 | pip install . 53 | ``` 54 | 55 | After this, the executable is available at `.venv/bin/toot`. 56 | 57 | To install with optonal image support: 58 | 59 | ``` 60 | pip install ".[images]" 61 | ``` -------------------------------------------------------------------------------- /toot/tui/NOTES.md: -------------------------------------------------------------------------------- 1 | Interesting urwid implementations: 2 | * https://github.com/CanonicalLtd/subiquity/blob/master/subiquitycore/core.py#L280 3 | * https://github.com/TomasTomecek/sen/blob/master/sen/tui/ui.py 4 | * https://github.com/rndusr/stig/tree/master/stig/tui 5 | 6 | Check out: 7 | * https://github.com/rr-/urwid_readline - better edit box? 8 | * https://github.com/prompt-toolkit/python-prompt-toolkit 9 | 10 | TODO/Ideas: 11 | * pack left column in timeline view 12 | * allow scrolling of toot contents if they don't fit the screen, perhaps using 13 | pageup/pagedown 14 | * consider adding semi-automated error reporting when viewing an exception, 15 | something along the lines of "press T to submit a ticket", which would link 16 | to a pre-filled issue submit page. 17 | * show new toots, some ideas: 18 | * R to reload/refresh timeline 19 | * streaming new toots? not sold on the idea 20 | * go up on first toot to fetch any newer ones, and prepend them? 21 | * Switch timeline to top/bottom layout for narrow views. 22 | * Think about how to show media 23 | * download media and use local image viewer? 24 | * convert to ascii art? 25 | * interaction with clipboard - how to copy a status to clipboard? 26 | * Show **notifications** 27 | * Status source 28 | * shortcut to copy source 29 | * syntax highlighting? 30 | * reblog 31 | * show author in status list, not person who reblogged 32 | * "v" should open the reblogged status, status.url is empty for the reblog 33 | * overlays 34 | * stack overlays instead of having one? 35 | * current bug: press U G Q Q - second Q closes the app instead of closing the overlay 36 | 37 | Questions: 38 | * is it possible to make a span a urwid.Text selectable? e.g. for urls and hashtags 39 | -------------------------------------------------------------------------------- /scripts/tag_version: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Creates an annotated git tag for a given version number. 5 | 6 | The tag will include the version number and changes for given version. 7 | 8 | Usage: tag_version [version] 9 | """ 10 | 11 | import subprocess 12 | import sys 13 | import textwrap 14 | import yaml 15 | 16 | from datetime import date 17 | from os import path 18 | 19 | path = path.join(path.dirname(path.dirname(path.abspath(__file__))), "changelog.yaml") 20 | with open(path, "r") as f: 21 | changelog = yaml.safe_load(f) 22 | 23 | if len(sys.argv) != 2: 24 | print("Wrong argument count", file=sys.stderr) 25 | sys.exit(1) 26 | 27 | version = sys.argv[1] 28 | 29 | changelog_item = changelog.get(version) 30 | if not changelog_item: 31 | print(f"Version `{version}` not found in changelog.", file=sys.stderr) 32 | sys.exit(1) 33 | 34 | release_date = changelog_item["date"] 35 | description = changelog_item.get("description") 36 | changes = changelog_item["changes"] 37 | 38 | if not isinstance(release_date, date): 39 | print(f"Release date not set for version `{version}` in the changelog.", file=sys.stderr) 40 | sys.exit(1) 41 | 42 | commit_message = f"toot {version}\n\n" 43 | 44 | if description: 45 | commit_message += textwrap.dedent(description) 46 | commit_message += "\n\n" 47 | 48 | for c in changes: 49 | lines = textwrap.wrap(c, 70) 50 | initial = True 51 | for line in lines: 52 | lead = " *" if initial else " " 53 | initial = False 54 | commit_message += f"{lead} {line}\n" 55 | 56 | proc = subprocess.run(["git", "tag", "-a", version, "-m", commit_message]) 57 | if proc.returncode != 0: 58 | sys.exit(1) 59 | 60 | print() 61 | print(commit_message) 62 | print() 63 | print(f"Version {version} tagged \\o/") 64 | -------------------------------------------------------------------------------- /docs/introduction.md: -------------------------------------------------------------------------------- 1 | toot - Mastodon CLI client 2 | ========================== 3 | 4 | ![Toot trumpet logo](./trumpet.png) 5 | 6 | Toot is a CLI and TUI tool for interacting with Mastodon (and other compatible) instances from the command line. 7 | 8 | [![](https://img.shields.io/badge/author-%40ihabunek-blue.svg?maxAge=3600&style=flat-square)](https://mastodon.social/@ihabunek) 9 | [![](https://img.shields.io/github/license/ihabunek/toot.svg?maxAge=3600&style=flat-square)](https://opensource.org/licenses/GPL-3.0) 10 | [![](https://img.shields.io/pypi/v/toot.svg?maxAge=3600&style=flat-square)](https://pypi.python.org/pypi/toot) 11 | 12 | Resources 13 | --------- 14 | 15 | * [Documentation](https://toot.bezdomni.net/) 16 | * [Source code on GitHub](https://github.com/ihabunek/toot) 17 | * [Issues on GitHub](https://github.com/ihabunek/toot/issues) 18 | * [Mailing list on Sourcehut](https://lists.sr.ht/~ihabunek/toot-discuss) for discussion, support and patches 19 | * Informal discussion on the #toot IRC channel on [libera.chat](https://libera.chat/) 20 | 21 | Command line client 22 | ------------------- 23 | 24 | * Posting, replying, deleting, favouriting, reblogging & pinning statuses 25 | * Support for media uploads, spoiler text, sensitive content 26 | * Search by account or hash tag 27 | * Following, muting and blocking accounts 28 | * Simple switching between multiple Mastodon accounts 29 | 30 | Terminal User Interface 31 | ----------------------- 32 | 33 | toot includes a terminal user interface. Run it with `toot tui`. 34 | 35 | ![](images/tui_list.png) 36 | 37 | ![](images/tui_poll.png) 38 | 39 | ![](images/tui_compose.png) 40 | 41 | License 42 | ------- 43 | 44 | Copyright Ivan Habunek and contributors. 45 | 46 | Licensed under the [GPLv3](http://www.gnu.org/licenses/gpl-3.0.html) license. 47 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=64", "setuptools_scm>=8"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "toot" 7 | authors = [{ name="Ivan Habunek", email="ivan@habunek.com" }] 8 | description = "Mastodon CLI client" 9 | readme = "README.rst" 10 | license = "GPL-3.0-only" 11 | requires-python = ">=3.9" 12 | dynamic = ["version"] 13 | 14 | classifiers = [ 15 | "Environment :: Console :: Curses", 16 | "Environment :: Console", 17 | "Operating System :: OS Independent", 18 | "Programming Language :: Python :: 3", 19 | ] 20 | 21 | dependencies = [ 22 | "beautifulsoup4>=4.5.0,<5.0", 23 | "click~=8.1", 24 | "python-dateutil>=2.8.1,<3.0", 25 | "requests>=2.13,<3.0", 26 | "tomlkit>=0.10.0,<1.0", 27 | "urwid~=3.0", 28 | "wcwidth>=0.1.7", 29 | ] 30 | 31 | [project.optional-dependencies] 32 | # Required to display images in the TUI 33 | images = [ 34 | "pillow>=9.5.0", 35 | "term-image>=0.7.2", 36 | ] 37 | 38 | [project.urls] 39 | "Homepage" = "https://toot.bezdomni.net" 40 | "Source" = "https://github.com/ihabunek/toot/" 41 | 42 | [project.scripts] 43 | toot = "toot.cli:cli" 44 | 45 | [tool.setuptools] 46 | packages=[ 47 | "toot", 48 | "toot.cli", 49 | "toot.tui", 50 | "toot.tui.richtext", 51 | "toot.urwidgets", 52 | "toot.utils", 53 | ] 54 | 55 | [tool.setuptools_scm] 56 | 57 | [tool.pyright] 58 | pythonVersion = "3.8" 59 | 60 | [tool.ruff] 61 | line-length = 100 62 | target-version = "py38" 63 | 64 | [dependency-groups] 65 | dev = [ 66 | "build", 67 | "flake8", 68 | "mypy", 69 | "pillow>=9.5.0", 70 | "pudb>=2025.1", 71 | "pyright", 72 | "pytest", 73 | "pytest-xdist[psutil]", 74 | "pyyaml", 75 | "setuptools", 76 | "twine", 77 | "types-beautifulsoup4", 78 | "typing-extensions", 79 | "vermin", 80 | ] 81 | -------------------------------------------------------------------------------- /toot/cache.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from pathlib import Path 5 | from typing import Optional 6 | 7 | from toot import App, User 8 | 9 | CACHE_SUBFOLDER = "toot" 10 | 11 | 12 | def save_last_post_id(app: App, user: User, id: str) -> None: 13 | """Save ID of the last post posted to this instance""" 14 | path = _last_post_id_path(app, user) 15 | with open(path, "w") as f: 16 | f.write(id) 17 | 18 | 19 | def get_last_post_id(app: App, user: User) -> Optional[str]: 20 | """Retrieve ID of the last post posted to this instance""" 21 | path = _last_post_id_path(app, user) 22 | if path.exists(): 23 | with open(path, "r") as f: 24 | return f.read() 25 | 26 | 27 | def clear_last_post_id(app: App, user: User) -> None: 28 | """Delete the cached last post ID for this instance""" 29 | path = _last_post_id_path(app, user) 30 | path.unlink(missing_ok=True) 31 | 32 | 33 | def _last_post_id_path(app: App, user: User): 34 | return get_cache_dir("last_post_ids") / f"{user.username}_{app.instance}" 35 | 36 | 37 | def get_cache_dir(subdir: Optional[str] = None) -> Path: 38 | path = _cache_dir_path() 39 | if subdir: 40 | path = path / subdir 41 | path.mkdir(parents=True, exist_ok=True) 42 | return path 43 | 44 | 45 | def _cache_dir_path() -> Path: 46 | """Returns the path to the cache directory""" 47 | 48 | # Windows 49 | if sys.platform == "win32" and "LOCALAPPDATA" in os.environ: 50 | return Path(os.environ["LOCALAPPDATA"], CACHE_SUBFOLDER) 51 | 52 | # Mac OS 53 | if sys.platform == "darwin": 54 | return Path.home() / "Library" / "Caches" / CACHE_SUBFOLDER 55 | 56 | # Respect XDG_CONFIG_HOME env variable if set 57 | # https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html 58 | if "XDG_CACHE_HOME" in os.environ: 59 | return Path(os.environ["XDG_CACHE_HOME"], CACHE_SUBFOLDER) 60 | 61 | return Path.home() / ".cache" / CACHE_SUBFOLDER 62 | -------------------------------------------------------------------------------- /toot/logging.py: -------------------------------------------------------------------------------- 1 | import json 2 | import sys 3 | 4 | from logging import getLogger 5 | from requests import Request, RequestException, Response 6 | from urllib.parse import urlencode 7 | 8 | logger = getLogger("toot") 9 | 10 | VERBOSE = "--verbose" in sys.argv 11 | 12 | 13 | def censor_secrets(headers): 14 | def _censor(k, v): 15 | if k == "Authorization": 16 | return (k, "***CENSORED***") 17 | return k, v 18 | 19 | return {_censor(k, v) for k, v in headers.items()} 20 | 21 | 22 | def truncate(line): 23 | if not VERBOSE and len(line) > 100: 24 | return line[:100] + "…" 25 | 26 | return line 27 | 28 | 29 | def log_request(request: Request): 30 | logger.debug(f" --> {request.method} {_url(request)}") 31 | 32 | if VERBOSE and request.headers: 33 | headers = censor_secrets(request.headers) 34 | logger.debug(f" --> HEADERS: {headers}") 35 | 36 | if VERBOSE and request.data: 37 | data = truncate(request.data) 38 | logger.debug(f" --> DATA: {data}") 39 | 40 | if VERBOSE and request.json: 41 | data = truncate(json.dumps(request.json)) 42 | logger.debug(f" --> JSON: {data}") 43 | 44 | if VERBOSE and request.files: 45 | logger.debug(f" --> FILES: {request.files}") 46 | 47 | 48 | def log_response(response: Response): 49 | method = response.request.method 50 | url = response.request.url 51 | elapsed = response.elapsed.microseconds // 1000 52 | logger.debug(f" <-- {method} {url} HTTP {response.status_code} {elapsed}ms") 53 | 54 | if VERBOSE and response.content: 55 | content = truncate(response.content.decode()) 56 | logger.debug(f" <-- {content}") 57 | 58 | 59 | def log_request_exception(request: Request, ex: RequestException): 60 | logger.debug(f" <-- {request.method} {_url(request)} Exception: {ex}") 61 | 62 | 63 | def _url(request): 64 | url = request.url 65 | if request.params: 66 | url += f"?{urlencode(request.params)}" 67 | return url 68 | -------------------------------------------------------------------------------- /docs/testing.md: -------------------------------------------------------------------------------- 1 | # Running toot tests 2 | 3 | toot has a series of integration tests to make sure it works with different Mastodon-compatible servers. This makes it a bit harder to run tests because we need a local instance to run the tests agains. 4 | 5 | **Please, never run tests against a live instance!** 6 | 7 | ## Mastodon 8 | 9 | Clone mastodon repo and check out the tag you want to test: 10 | 11 | ``` 12 | git clone https://github.com/mastodon/mastodon 13 | cd mastodon 14 | git checkout v4.2.8 15 | ``` 16 | 17 | Set up the required Ruby version using [ASDF](https://asdf-vm.com/). The 18 | required version is listed in `.ruby-version`. 19 | 20 | ``` 21 | asdf install ruby 3.2.3 22 | asdf local ruby 3.2.3 23 | ``` 24 | 25 | Install and set up database: 26 | 27 | ``` 28 | bundle install 29 | yarn install 30 | rails db:setup 31 | ``` 32 | 33 | Patch code so users are auto-approved: 34 | 35 | ``` 36 | curl https://paste.sr.ht/blob/7c6e08bbacf3da05366b3496b3f24dd03d60bd6d | git am 37 | ``` 38 | 39 | Open registrations: 40 | 41 | ``` 42 | bin/tootctl settings registration open 43 | ``` 44 | 45 | Install foreman to run the thing: 46 | 47 | ``` 48 | gem install foreman 49 | ``` 50 | 51 | Start the server: 52 | 53 | ``` 54 | foreman start 55 | ``` 56 | 57 | The server should now be live at: http://localhost:3000/ 58 | 59 | You can view any emails sent by Mastodon at: http://localhost:3000/letter_opener/ 60 | 61 | ## Pleroma 62 | 63 | https://docs-develop.pleroma.social/backend/development/setting_up_pleroma_dev/ 64 | 65 | ## Sharkey 66 | 67 | Testing toot on [Sharkey](https://activitypub.software/TransFem-org/Sharkey/) 68 | 69 | Requires: 70 | * postgresql 71 | * redis 72 | * node + pnpm 73 | 74 | ```sh 75 | git clone https://activitypub.software/TransFem-org/Sharkey.git 76 | cd Sharkey 77 | git submodule update --init 78 | 79 | cp .config/example.yml .config/default.yml 80 | vim .config/default.yml 81 | # Edit these keys: 82 | # * db - put in your database credentials 83 | # * setupPassword - set any password, we'll use "toot" 84 | 85 | createdb sharkey 86 | pnpm install --frozen-lockfile 87 | pnpm build 88 | pnpm migrate 89 | pnpm dev 90 | ``` 91 | 92 | Now sharkey should be started. Visit localhost:3000 and create an admin account using `setupPassword` defined in the config file. 93 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ============================ 2 | Toot - a Mastodon CLI client 3 | ============================ 4 | 5 | .. image:: https://raw.githubusercontent.com/ihabunek/toot/master/trumpet.png 6 | 7 | Toot is a CLI and TUI tool for interacting with Mastodon instances from the command line. 8 | 9 | .. image:: https://img.shields.io/badge/author-%40ihabunek-blue.svg?maxAge=3600&style=flat-square 10 | :target: https://mastodon.social/@ihabunek 11 | .. image:: https://img.shields.io/github/license/ihabunek/toot.svg?maxAge=3600&style=flat-square 12 | :target: https://opensource.org/licenses/GPL-3.0 13 | .. image:: https://img.shields.io/pypi/v/toot.svg?maxAge=3600&style=flat-square 14 | :target: https://pypi.python.org/pypi/toot 15 | 16 | Resources 17 | --------- 18 | 19 | * Installation instructions: https://toot.bezdomni.net/installation.html 20 | * Homepage: https://github.com/ihabunek/toot 21 | * Issues: https://github.com/ihabunek/toot/issues 22 | * Documentation: https://toot.bezdomni.net/ 23 | * Mailing list for discussion, support and patches: 24 | https://lists.sr.ht/~ihabunek/toot-discuss 25 | * Informal discussion: `#toot` IRC channel on `libera.chat `_ 26 | 27 | Features 28 | -------- 29 | 30 | * Posting, replying, deleting statuses 31 | * Support for media uploads, spoiler text, sensitive content 32 | * Search by account or hash tag 33 | * Following, muting and blocking accounts 34 | * Simple switching between authenticated in Mastodon accounts 35 | 36 | Terminal User Interface 37 | ----------------------- 38 | 39 | toot includes a terminal user interface (TUI). Run it with ``toot tui``. 40 | 41 | TUI Features: 42 | ------------- 43 | 44 | * Block graphic image display (requires optional libraries `pillow `, `term-image `, and `urwidgets `) 45 | * Bitmapped image display in `kitty ` terminal ``toot tui -f kitty`` 46 | * Bitmapped image display in `iTerm2 `, or `WezTerm ` terminal ``toot tui -f iterm`` 47 | 48 | 49 | .. image :: https://raw.githubusercontent.com/ihabunek/toot/master/docs/images/tui_list.png 50 | 51 | .. image :: https://raw.githubusercontent.com/ihabunek/toot/master/docs/images/tui_compose.png 52 | 53 | License 54 | ------- 55 | 56 | Copyright Ivan Habunek and contributors. 57 | 58 | Licensed under `GPLv3 `_, see `LICENSE `_. 59 | -------------------------------------------------------------------------------- /toot/auth.py: -------------------------------------------------------------------------------- 1 | from toot import api, config, User, App 2 | from toot.entities import from_dict, Instance 3 | from toot.exceptions import ApiError, ConsoleError 4 | from urllib.parse import urlparse 5 | 6 | 7 | def find_instance(base_url: str) -> Instance: 8 | try: 9 | instance = api.get_instance(base_url).json() 10 | return from_dict(Instance, instance) 11 | except ApiError: 12 | raise ConsoleError(f"Instance not found at {base_url}") 13 | 14 | 15 | def register_app(domain: str, base_url: str) -> App: 16 | try: 17 | response = api.create_app(base_url) 18 | except ApiError: 19 | raise ConsoleError("Registration failed.") 20 | 21 | app = App(domain, base_url, response['client_id'], response['client_secret']) 22 | config.save_app(app) 23 | 24 | return app 25 | 26 | 27 | def get_or_create_app(base_url: str) -> App: 28 | instance = find_instance(base_url) 29 | domain = _get_instance_domain(instance) 30 | return config.load_app(domain) or register_app(domain, base_url) 31 | 32 | 33 | def create_user(app: App, access_token: str) -> User: 34 | # Username is not yet known at this point, so fetch it from Mastodon 35 | user = User(app.instance, None, access_token) 36 | creds = api.verify_credentials(app, user).json() 37 | 38 | user = User(app.instance, creds["username"], access_token) 39 | config.save_user(user, activate=True) 40 | 41 | return user 42 | 43 | 44 | def login_username_password(app: App, email: str, password: str) -> User: 45 | try: 46 | response = api.login(app, email, password) 47 | except Exception: 48 | raise ConsoleError("Login failed") 49 | 50 | return create_user(app, response["access_token"]) 51 | 52 | 53 | def login_auth_code(app: App, authorization_code: str) -> User: 54 | try: 55 | response = api.request_access_token(app, authorization_code) 56 | except Exception: 57 | raise ConsoleError("Login failed") 58 | 59 | return create_user(app, response["access_token"]) 60 | 61 | 62 | def _get_instance_domain(instance: Instance) -> str: 63 | """Extracts the instance domain name. 64 | 65 | Pleroma and its forks return an actual URI here, rather than a domain name 66 | like Mastodon. This is contrary to the spec.¯ in that case, parse out the 67 | domain and return it. 68 | 69 | TODO: when updating to v2 instance endpoint, this field has been renamed to 70 | `domain` 71 | """ 72 | if instance.uri.startswith("http"): 73 | return urlparse(instance.uri).netloc 74 | return instance.uri 75 | -------------------------------------------------------------------------------- /toot/cli/tui.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from typing import Optional 4 | from toot.cli import TUI_COLORS, VISIBILITY_CHOICES, IMAGE_FORMAT_CHOICES, Context, cli, pass_context 5 | from toot.cli.validators import validate_tui_colors, validate_cache_size 6 | from toot.tui.app import TUI, TuiOptions 7 | 8 | COLOR_OPTIONS = ", ".join(TUI_COLORS.keys()) 9 | 10 | 11 | @cli.command() 12 | @click.option( 13 | "-r", "--relative-datetimes", 14 | is_flag=True, 15 | help="Show relative datetimes in status list" 16 | ) 17 | @click.option( 18 | "-m", "--media-viewer", 19 | help="Program to invoke with media URLs to display the media files, such as 'feh'" 20 | ) 21 | @click.option( 22 | "-c", "--colors", 23 | callback=validate_tui_colors, 24 | help=f"""Number of colors to use, one of {COLOR_OPTIONS}, defaults to 16 if 25 | using --color, and 1 if using --no-color.""" 26 | ) 27 | @click.option( 28 | "--cache-size", 29 | callback=validate_cache_size, 30 | help="""Specify the image cache maximum size in megabytes. Default: 10MB. 31 | Minimum: 1MB.""" 32 | ) 33 | @click.option( 34 | "-v", "--default-visibility", 35 | type=click.Choice(VISIBILITY_CHOICES), 36 | help="Default visibility when posting new toots; overrides the server-side preference" 37 | ) 38 | @click.option( 39 | "-s", "--always-show-sensitive", 40 | is_flag=True, 41 | help="Expand toots with content warnings automatically" 42 | ) 43 | @click.option( 44 | "-f", "--image-format", 45 | type=click.Choice(IMAGE_FORMAT_CHOICES), 46 | help="Image output format; support varies across terminals. Default: block" 47 | ) 48 | @click.option( 49 | "--show-display-names", 50 | is_flag=True, 51 | default=False, 52 | help="Show display names instead of account names in the list view." 53 | ) 54 | @pass_context 55 | def tui( 56 | ctx: Context, 57 | colors: Optional[int], 58 | media_viewer: Optional[str], 59 | always_show_sensitive: bool, 60 | relative_datetimes: bool, 61 | cache_size: Optional[int], 62 | default_visibility: Optional[str], 63 | image_format: Optional[str], 64 | show_display_names: bool, 65 | ): 66 | """Launches the toot terminal user interface""" 67 | if colors is None: 68 | colors = 16 if ctx.color else 1 69 | 70 | options = TuiOptions( 71 | colors=colors, 72 | media_viewer=media_viewer, 73 | relative_datetimes=relative_datetimes, 74 | cache_size=cache_size, 75 | default_visibility=default_visibility, 76 | always_show_sensitive=always_show_sensitive, 77 | image_format=image_format, 78 | show_display_names=show_display_names, 79 | ) 80 | tui = TUI.create(ctx.app, ctx.user, options) 81 | tui.run() 82 | -------------------------------------------------------------------------------- /toot/cli/validators.py: -------------------------------------------------------------------------------- 1 | import click 2 | import re 3 | 4 | from click import Context 5 | from typing import Optional 6 | 7 | from toot.cli import TUI_COLORS 8 | 9 | 10 | def validate_language(ctx: Context, param: str, value: Optional[str]): 11 | if value is None: 12 | return None 13 | 14 | value = value.strip().lower() 15 | if re.match(r"^[a-z]{2}$", value): 16 | return value 17 | 18 | raise click.BadParameter("Language should be a two letter abbreviation.") 19 | 20 | 21 | def validate_duration(ctx: Context, param: str, value: Optional[str]) -> Optional[int]: 22 | if value is None: 23 | return None 24 | 25 | match = re.match(r"""^ 26 | (([0-9]+)\s*(days|day|d))?\s* 27 | (([0-9]+)\s*(hours|hour|h))?\s* 28 | (([0-9]+)\s*(minutes|minute|m))?\s* 29 | (([0-9]+)\s*(seconds|second|s))?\s* 30 | $""", value, re.X) 31 | 32 | if not match: 33 | raise click.BadParameter(f"Invalid duration: {value}") 34 | 35 | days = match.group(2) 36 | hours = match.group(5) 37 | minutes = match.group(8) 38 | seconds = match.group(11) 39 | 40 | days = int(match.group(2) or 0) * 60 * 60 * 24 41 | hours = int(match.group(5) or 0) * 60 * 60 42 | minutes = int(match.group(8) or 0) * 60 43 | seconds = int(match.group(11) or 0) 44 | 45 | duration = days + hours + minutes + seconds 46 | 47 | if duration == 0: 48 | raise click.BadParameter("Empty duration") 49 | 50 | return duration 51 | 52 | 53 | def validate_instance(ctx: click.Context, param: str, value: Optional[str]): 54 | """ 55 | Instance can be given either as a base URL or the domain name. 56 | Return the base URL. 57 | """ 58 | if not value: 59 | return None 60 | 61 | value = value.rstrip("/") 62 | return value if value.startswith("http") else f"https://{value}" 63 | 64 | 65 | def validate_tui_colors(ctx, param, value) -> Optional[int]: 66 | if value is None: 67 | return None 68 | 69 | if value in TUI_COLORS.values(): 70 | return value 71 | 72 | if value in TUI_COLORS.keys(): 73 | return TUI_COLORS[value] 74 | 75 | raise click.BadParameter(f"Invalid value: {value}. Expected one of: {', '.join(TUI_COLORS)}") 76 | 77 | 78 | def validate_cache_size(ctx: click.Context, param: str, value: Optional[str]) -> Optional[int]: 79 | """validates the cache size parameter""" 80 | 81 | if value is None: 82 | return 1024 * 1024 * 10 # default 10MB 83 | else: 84 | if value.isdigit(): 85 | size = int(value) 86 | else: 87 | raise click.BadParameter("Cache size must be numeric.") 88 | 89 | if size > 1024: 90 | raise click.BadParameter("Cache size too large: 1024MB maximum.") 91 | elif size < 1: 92 | raise click.BadParameter("Cache size too small: 1MB minimum.") 93 | return size 94 | 95 | 96 | def validate_positive(_ctx: click.Context, _param: click.Parameter, value: Optional[int]): 97 | if value is not None and value <= 0: 98 | raise click.BadParameter("must be greater than 0") 99 | return value 100 | -------------------------------------------------------------------------------- /toot/tui/entities.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | 3 | from toot.utils.datetime import parse_datetime 4 | 5 | Author = namedtuple("Author", ["account", "display_name", "username"]) 6 | 7 | 8 | class Status: 9 | """ 10 | A wrapper around the Status entity data fetched from Mastodon. 11 | 12 | https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md#status 13 | 14 | Attributes 15 | ---------- 16 | reblog : Status or None 17 | The reblogged status if it exists. 18 | 19 | original : Status 20 | If a reblog, the reblogged status, otherwise self. 21 | """ 22 | 23 | def __init__(self, data, is_mine, default_instance): 24 | """ 25 | Parameters 26 | ---------- 27 | data : dict 28 | Status data as received from Mastodon. 29 | https://docs.joinmastodon.org/api/entities/#status 30 | 31 | is_mine : bool 32 | Whether the status was created by the logged in user. 33 | 34 | default_instance : str 35 | The domain of the instance into which the user is logged in. Used to 36 | create fully qualified account names for users on the same instance. 37 | Mastodon only populates the name, not the domain. 38 | """ 39 | 40 | self.data = data 41 | self.is_mine = is_mine 42 | self.default_instance = default_instance 43 | 44 | # This can be toggled by the user 45 | self.show_sensitive = False 46 | 47 | # Set when status is translated 48 | self.show_translation = False 49 | self.translation = None 50 | self.translated_from = None 51 | 52 | # TODO: clean up 53 | self.id = self.data["id"] 54 | self.account = self._get_account() 55 | self.created_at = parse_datetime(data["created_at"]) 56 | if data.get("edited_at"): 57 | self.edited_at = parse_datetime(data["edited_at"]) 58 | else: 59 | self.edited_at = None 60 | self.author = self._get_author() 61 | self.favourited = data.get("favourited", False) 62 | self.reblogged = data.get("reblogged", False) 63 | self.bookmarked = data.get("bookmarked", False) 64 | self.in_reply_to = data.get("in_reply_to_id") 65 | self.url = data.get("url") 66 | self.mentions = data.get("mentions") 67 | self.reblog = self._get_reblog() 68 | self.visibility = data.get("visibility") 69 | 70 | @property 71 | def original(self): 72 | return self.reblog or self 73 | 74 | def _get_reblog(self): 75 | reblog = self.data.get("reblog") 76 | if not reblog: 77 | return None 78 | 79 | reblog_is_mine = self.is_mine and ( 80 | self.data["account"]["acct"] == reblog["account"]["acct"] 81 | ) 82 | return Status(reblog, reblog_is_mine, self.default_instance) 83 | 84 | def _get_author(self): 85 | acct = self.data['account']['acct'] 86 | acct = acct if "@" in acct else "{}@{}".format(acct, self.default_instance) 87 | return Author(acct, self.data['account']['display_name'], self.data['account']['username']) 88 | 89 | def _get_account(self): 90 | acct = self.data['account']['acct'] 91 | return acct if "@" in acct else "{}@{}".format(acct, self.default_instance) 92 | 93 | def __repr__(self): 94 | return "".format(self.id, self.account) 95 | -------------------------------------------------------------------------------- /docs/settings.md: -------------------------------------------------------------------------------- 1 | # Settings 2 | 3 | Toot can be configured via a [TOML](https://toml.io/en/) settings file. 4 | 5 | > Introduced in toot 0.37.0 6 | 7 | > **Warning:** Settings are experimental and things may change without warning. 8 | 9 | Toot will look for the settings file at: 10 | 11 | * `~/.config/toot/settings.toml` (Linux & co.) 12 | * `%APPDATA%\toot\settings.toml` (Windows) 13 | 14 | Toot will respect the `XDG_CONFIG_HOME` environment variable if it's set and 15 | look for the settings file in `$XDG_CONFIG_HOME/toot` instead of 16 | `~/.config/toot`. 17 | 18 | ## Common options 19 | 20 | The `[common]` section includes common options which are applied to all commands. 21 | 22 | ```toml 23 | [common] 24 | # Whether to use ANSI color in output 25 | color = true 26 | 27 | # Enable debug logging, shows HTTP requests 28 | debug = true 29 | 30 | # Redirect debug log to the given file 31 | debug_file = "/tmp/toot.log" 32 | 33 | # Log request and response bodies in the debug log 34 | verbose = false 35 | 36 | # Do not write to output 37 | quiet = false 38 | ``` 39 | 40 | ## Overriding command defaults 41 | 42 | Defaults for command arguments can be override by specifying a `[commands.]` section. 43 | 44 | For example, to override `toot post`. 45 | 46 | ```toml 47 | [commands.post] 48 | editor = "vim" 49 | sensitive = true 50 | visibility = "unlisted" 51 | scheduled_in = "30 minutes" 52 | ``` 53 | 54 | ## TUI view images 55 | 56 | > Introduced in toot 0.39.0 57 | 58 | You can view images in a toot using an external program by setting the 59 | `tui.media_viewer` option to your desired image viewer. When a toot is focused, 60 | pressing `m` will launch the specified executable giving one or more URLs as 61 | arguments. This works well with image viewers like `feh` which accept URLs as 62 | arguments. 63 | 64 | ```toml 65 | [tui] 66 | media_viewer = "feh" 67 | ``` 68 | 69 | ## TUI color palette 70 | 71 | TUI uses Urwid which provides several color modes. See 72 | [Urwid documentation](https://urwid.org/manual/displayattributes.html) 73 | for more details. 74 | 75 | By default, TUI operates in 16-color mode which can be changed by setting the 76 | `color` setting in the `[tui]` section to one of the following values: 77 | 78 | * `1` (monochrome) 79 | * `16` (default) 80 | * `88` 81 | * `256` 82 | * `16777216` (24 bit) 83 | 84 | TUI defines a list of colors which can be customized, currently they can be seen 85 | [in the source code](https://github.com/ihabunek/toot/blob/master/toot/tui/constants.py). They can be overridden in the `[tui.palette]` section. 86 | 87 | Each color is defined as a list of upto 5 values: 88 | 89 | * foreground color (16 color mode) 90 | * background color (16 color mode) 91 | * monochrome color (monochrome mode) 92 | * foreground color (high-color mode) 93 | * background color (high-color mode) 94 | 95 | Any colors which are not used by your desired color mode can be skipped or set 96 | to an empty string. 97 | 98 | For example, to change the button colors in 16 color mode: 99 | 100 | ```toml 101 | [tui.palette] 102 | button = ["dark red,bold", ""] 103 | button_focused = ["light gray", "green"] 104 | ``` 105 | 106 | In monochrome mode: 107 | 108 | ```toml 109 | [tui] 110 | colors = 1 111 | 112 | [tui.palette] 113 | button = ["", "", "bold"] 114 | button_focused = ["", "", "italics"] 115 | ``` 116 | 117 | In 256 color mode: 118 | 119 | ```toml 120 | [tui] 121 | colors = 256 122 | 123 | [tui.palette] 124 | button = ["", "", "", "#aaa", "#bbb"] 125 | button_focused = ["", "", "", "#aaa", "#bbb"] 126 | ``` 127 | -------------------------------------------------------------------------------- /toot/tui/constants.py: -------------------------------------------------------------------------------- 1 | # Color definitions are tuples of: 2 | # - name 3 | # - foreground (normal mode) 4 | # - background (normal mode) 5 | # - foreground (monochrome mode) 6 | # - foreground (high color mode) 7 | # - background (high color mode) 8 | # 9 | # See: 10 | # http://urwid.org/tutorial/index.html#display-attributes 11 | # http://urwid.org/manual/displayattributes.html#using-display-attributes 12 | 13 | PALETTE = [ 14 | # Components 15 | ('button', 'white', 'black'), 16 | ('button_focused', 'light gray', 'dark magenta', 'bold,underline'), 17 | ('card_author', 'yellow', ''), 18 | ('card_title', 'dark green', ''), 19 | ('columns_divider', 'white', 'dark blue'), 20 | ('content_warning', 'white', 'dark magenta'), 21 | ('editbox', 'white', 'black'), 22 | ('editbox_focused', 'white', 'dark magenta'), 23 | ('footer_message', 'dark green', ''), 24 | ('footer_message_error', 'light red', ''), 25 | ('footer_status', 'white', 'dark blue'), 26 | ('footer_status_bold', 'white, bold', 'dark blue'), 27 | ('header', 'white', 'dark blue'), 28 | ('header_bold', 'white,bold', 'dark blue', 'bold'), 29 | ('intro_bigtext', 'yellow', ''), 30 | ('intro_smalltext', 'light blue', ''), 31 | ('poll_bar', 'white', 'dark blue'), 32 | ('status_detail_account', 'dark green', ''), 33 | ('status_detail_bookmarked', 'light red', ''), 34 | ('status_detail_timestamp', 'light blue', ''), 35 | ('status_list_account', 'dark green', ''), 36 | ('status_list_selected', 'white,bold', 'dark green', 'bold,underline'), 37 | ('status_list_timestamp', 'light blue', ''), 38 | 39 | # Functional 40 | ('account', 'dark green', ''), 41 | ('hashtag', 'light cyan,bold', '', 'bold'), 42 | ('hashtag_followed', 'yellow,bold', '', 'bold'), 43 | ('link', ',italics', '', ',italics'), 44 | ('link_focused', ',italics', 'dark magenta', "underline,italics"), 45 | ('shortcut', 'light blue', ''), 46 | ('shortcut_highlight', 'white,bold', '', 'bold'), 47 | ('warning', 'light red', ''), 48 | 49 | # Visibility 50 | ('visibility_public', 'dark gray', ''), 51 | ('visibility_unlisted', 'white', ''), 52 | ('visibility_private', 'dark cyan', ''), 53 | ('visibility_direct', 'yellow', ''), 54 | 55 | # Styles 56 | ('bold', ',bold', ''), 57 | ('dim', 'dark gray', ''), 58 | ('highlight', 'yellow', ''), 59 | ('success', 'dark green', ''), 60 | 61 | # HTML tag styling 62 | ('a', ',italics', '', 'italics'), 63 | # em tag is mapped to i 64 | ('i', ',italics', '', 'italics'), 65 | # strong tag is mapped to b 66 | ('b', ',bold', '', 'bold'), 67 | # special case for bold + italic nested tags 68 | ('bi', ',bold,italics', '', ',bold,italics'), 69 | ('u', ',underline', '', ',underline'), 70 | ('del', ',strikethrough', '', ',strikethrough'), 71 | ('code', 'light gray, standout', '', ',standout'), 72 | ('pre', 'light gray, standout', '', ',standout'), 73 | ('blockquote', 'light gray', '', ''), 74 | ('h1', ',bold', '', ',bold'), 75 | ('h2', ',bold', '', ',bold'), 76 | ('h3', ',bold', '', ',bold'), 77 | ('h4', ',bold', '', ',bold'), 78 | ('h5', ',bold', '', ',bold'), 79 | ('h6', ',bold', '', ',bold'), 80 | ('class_mention_hashtag', 'light cyan', '', ''), 81 | ('class_hashtag', 'light cyan', '', ''), 82 | 83 | ] 84 | 85 | VISIBILITY_OPTIONS = [ 86 | ("public", "Public", "Post to public timelines"), 87 | ("unlisted", "Unlisted", "Do not post to public timelines"), 88 | ("private", "Private", "Post to followers only"), 89 | ("direct", "Direct", "Post to mentioned users only"), 90 | ] 91 | -------------------------------------------------------------------------------- /toot/tui/widgets.py: -------------------------------------------------------------------------------- 1 | import urwid 2 | from wcwidth import wcswidth 3 | 4 | 5 | class Clickable: 6 | """ 7 | Add a `click` signal which is sent when the item is activated or clicked. 8 | 9 | TODO: make it work on widgets which have other signals. 10 | """ 11 | signals = ["click"] 12 | 13 | def keypress(self, size, key): 14 | if self._command_map[key] == urwid.ACTIVATE: 15 | self._emit('click') 16 | return 17 | 18 | return key 19 | 20 | def mouse_event(self, size, event, button, x, y, focus): 21 | if button == 1: 22 | self._emit('click') 23 | 24 | 25 | class SelectableText(Clickable, urwid.Text): 26 | _selectable = True 27 | 28 | 29 | class SelectableColumns(Clickable, urwid.Columns): 30 | _selectable = True 31 | 32 | 33 | class EditBox(urwid.AttrWrap): 34 | """Styled edit box.""" 35 | def __init__(self, *args, **kwargs): 36 | self.edit = urwid.Edit(*args, **kwargs) 37 | return super().__init__(self.edit, "editbox", "editbox_focused") 38 | 39 | 40 | class Button(urwid.AttrWrap): 41 | """Styled button.""" 42 | def __init__(self, *args, **kwargs): 43 | button = urwid.Button(*args, **kwargs) 44 | padding = urwid.Padding(button, width=wcswidth(args[0]) + 4) 45 | return super().__init__(padding, "button", "button_focused") 46 | 47 | def set_label(self, *args, **kwargs): 48 | self.original_widget.original_widget.set_label(*args, **kwargs) 49 | self.original_widget.width = wcswidth(args[0]) + 4 50 | 51 | 52 | class CheckBox(urwid.AttrWrap): 53 | """Styled checkbox.""" 54 | def __init__(self, *args, **kwargs): 55 | self.button = urwid.CheckBox(*args, **kwargs) 56 | padding = urwid.Padding(self.button, width=len(args[0]) + 4) 57 | return super().__init__(padding, "button", "button_focused") 58 | 59 | def get_state(self): 60 | """Return the state of the checkbox.""" 61 | return self.button.get_state() 62 | 63 | 64 | class RadioButton(urwid.AttrWrap): 65 | """Styled radiobutton.""" 66 | def __init__(self, *args, **kwargs): 67 | button = urwid.RadioButton(*args, **kwargs) 68 | padding = urwid.Padding(button, width=len(args[1]) + 4) 69 | return super().__init__(padding, "button", "button_focused") 70 | 71 | 72 | class ModalBox(urwid.Frame): 73 | def __init__(self, message): 74 | text = urwid.Text(message) 75 | filler = urwid.Filler(text, valign='top', top=1, bottom=1) 76 | padding = urwid.Padding(filler, left=1, right=1) 77 | return super().__init__(padding) 78 | 79 | 80 | class RoundedLineBox(urwid.LineBox): 81 | """LineBox that defaults to rounded corners.""" 82 | def __init__(self, 83 | original_widget, 84 | title="", 85 | title_align="center", 86 | title_attr=None, 87 | tlcorner="\u256d", 88 | tline="─", 89 | lline="│", 90 | trcorner="\u256e", 91 | blcorner="\u2570", 92 | rline="│", 93 | bline="─", 94 | brcorner="\u256f", 95 | ) -> None: 96 | return super().__init__(original_widget, 97 | title, 98 | title_align, 99 | title_attr, 100 | tlcorner, 101 | tline, 102 | lline, 103 | trcorner, 104 | blcorner, 105 | rline, 106 | bline, 107 | brcorner) 108 | -------------------------------------------------------------------------------- /toot/wcstring.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utilities for dealing with string containing wide characters. 3 | """ 4 | 5 | import re 6 | from typing import Generator, List 7 | 8 | from wcwidth import wcwidth, wcswidth 9 | 10 | 11 | def _wc_hard_wrap(line: str, length: int) -> Generator[str, None, None]: 12 | """ 13 | Wrap text to length characters, breaking when target length is reached, 14 | taking into account character width. 15 | 16 | Used to wrap lines which cannot be wrapped on whitespace. 17 | """ 18 | chars = [] 19 | chars_len = 0 20 | for char in line: 21 | char_len = wcwidth(char) 22 | if chars_len + char_len > length: 23 | yield "".join(chars) 24 | chars: List[str] = [] 25 | chars_len = 0 26 | 27 | chars.append(char) 28 | chars_len += char_len 29 | 30 | if chars: 31 | yield "".join(chars) 32 | 33 | 34 | def wc_wrap(text: str, length: int) -> Generator[str, None, None]: 35 | """ 36 | Wrap text to given length, breaking on whitespace and taking into account 37 | character width. 38 | 39 | Meant for use on a single line or paragraph. Will destroy spacing between 40 | words and paragraphs and any indentation. 41 | """ 42 | line_words: List[str] = [] 43 | line_len = 0 44 | 45 | words = re.split(r"\s+", text.strip()) 46 | for word in words: 47 | word_len = wcswidth(word) 48 | 49 | if line_words and line_len + word_len > length: 50 | line = " ".join(line_words) 51 | if line_len <= length: 52 | yield line 53 | else: 54 | yield from _wc_hard_wrap(line, length) 55 | 56 | line_words = [] 57 | line_len = 0 58 | 59 | line_words.append(word) 60 | line_len += word_len + 1 # add 1 to account for space between words 61 | 62 | if line_words: 63 | line = " ".join(line_words) 64 | if line_len <= length: 65 | yield line 66 | else: 67 | yield from _wc_hard_wrap(line, length) 68 | 69 | 70 | def trunc(text: str, length: int) -> str: 71 | """ 72 | Truncates text to given length, taking into account wide characters. 73 | 74 | If truncated, the last char is replaced by an ellipsis. 75 | """ 76 | if length < 1: 77 | raise ValueError("length should be 1 or larger") 78 | 79 | # Remove whitespace first so no unnecessary truncation is done. 80 | text = text.strip() 81 | text_length = wcswidth(text) 82 | 83 | if text_length <= length: 84 | return text 85 | 86 | # We cannot just remove n characters from the end since we don't know how 87 | # wide these characters are and how it will affect text length. 88 | # Use wcwidth to determine how many characters need to be truncated. 89 | chars_to_truncate = 0 90 | trunc_length = 0 91 | for char in reversed(text): 92 | chars_to_truncate += 1 93 | trunc_length += wcwidth(char) 94 | if text_length - trunc_length <= length: 95 | break 96 | 97 | # Additional char to make room for ellipsis 98 | n = chars_to_truncate + 1 99 | return text[:-n].strip() + '…' 100 | 101 | 102 | def pad(text: str, length: int) -> str: 103 | """Pads text to given length, taking into account wide characters.""" 104 | text_length = wcswidth(text) 105 | 106 | if text_length < length: 107 | return text + ' ' * (length - text_length) 108 | 109 | return text 110 | 111 | 112 | def fit_text(text: str, length: int) -> str: 113 | """Makes text fit the given length by padding or truncating it.""" 114 | text_length = wcswidth(text) 115 | 116 | if text_length > length: 117 | return trunc(text, length) 118 | 119 | if text_length < length: 120 | return pad(text, length) 121 | 122 | return text 123 | -------------------------------------------------------------------------------- /toot/tui/poll.py: -------------------------------------------------------------------------------- 1 | import urwid 2 | 3 | from toot import api 4 | from toot.exceptions import ApiError 5 | from toot.utils.datetime import parse_datetime 6 | from .widgets import Button, CheckBox, RadioButton, RoundedLineBox 7 | from .richtext import html_to_widgets 8 | 9 | 10 | class Poll(urwid.ListBox): 11 | """View and vote on a poll""" 12 | 13 | def __init__(self, app, user, status): 14 | self.status = status 15 | self.app = app 16 | self.user = user 17 | self.poll = status.original.data.get("poll") 18 | self.button_group = [] 19 | self.api_exception = None 20 | self.setup_listbox() 21 | 22 | def setup_listbox(self): 23 | actions = list(self.generate_contents(self.status)) 24 | walker = urwid.SimpleListWalker(actions) 25 | super().__init__(walker) 26 | 27 | def build_linebox(self, contents): 28 | contents = urwid.Pile(list(contents)) 29 | contents = urwid.Padding(contents, left=1, right=1) 30 | return RoundedLineBox(contents) 31 | 32 | def vote(self, button_widget): 33 | poll = self.status.original.data.get("poll") 34 | choices = [] 35 | for idx, button in enumerate(self.button_group): 36 | if button.get_state(): 37 | choices.append(idx) 38 | 39 | if len(choices): 40 | try: 41 | response = api.vote(self.app, self.user, poll["id"], choices=choices) 42 | self.status.original.data["poll"] = response 43 | self.api_exception = None 44 | self.poll["voted"] = True 45 | self.poll["own_votes"] = choices 46 | except ApiError as exception: 47 | self.api_exception = exception 48 | finally: 49 | self.setup_listbox() 50 | 51 | def generate_poll_detail(self): 52 | poll = self.poll 53 | 54 | self.button_group = [] # button group 55 | for idx, option in enumerate(poll["options"]): 56 | voted_for = ( 57 | poll["voted"] and poll["own_votes"] and idx in poll["own_votes"] 58 | ) 59 | 60 | if poll["voted"] or poll["expired"]: 61 | prefix = " ✓ " if voted_for else " " 62 | yield urwid.Text(("dim", prefix + f'{option["title"]}')) 63 | else: 64 | if poll["multiple"]: 65 | checkbox = CheckBox(f'{option["title"]}') 66 | self.button_group.append(checkbox) 67 | yield checkbox 68 | else: 69 | yield RadioButton(self.button_group, f'{option["title"]}') 70 | 71 | yield urwid.Divider() 72 | 73 | poll_detail = "Poll · {} votes".format(poll["votes_count"]) 74 | 75 | if poll["expired"]: 76 | poll_detail += " · Closed" 77 | 78 | if poll["expires_at"]: 79 | expires_at = parse_datetime(poll["expires_at"]).strftime( 80 | "%Y-%m-%d %H:%M" 81 | ) 82 | poll_detail += " · Closes on {}".format(expires_at) 83 | 84 | yield urwid.Text(("dim", poll_detail)) 85 | 86 | def generate_contents(self, status): 87 | yield urwid.Divider() 88 | 89 | widgetlist = html_to_widgets(status.data["content"]) 90 | 91 | for line in widgetlist: 92 | yield (line) 93 | 94 | yield urwid.Divider() 95 | yield self.build_linebox(self.generate_poll_detail()) 96 | yield urwid.Divider() 97 | 98 | if self.poll["voted"]: 99 | yield urwid.Text(("grey", "< Already Voted >")) 100 | elif not self.poll["expired"]: 101 | yield Button("Vote", on_press=self.vote) 102 | 103 | if self.api_exception: 104 | yield urwid.Divider() 105 | yield urwid.Text("warning", str(self.api_exception)) 106 | -------------------------------------------------------------------------------- /toot/cli/tags.py: -------------------------------------------------------------------------------- 1 | import json as pyjson 2 | 3 | import click 4 | 5 | from toot import api 6 | from toot.cli import Context, cli, json_option, pass_context 7 | from toot.entities import Tag, from_dict 8 | from toot.output import print_tag_list 9 | 10 | 11 | @cli.group() 12 | def tags(): 13 | """List, follow, and unfollow tags""" 14 | 15 | 16 | @tags.command() 17 | @click.argument("tag") 18 | @json_option 19 | @pass_context 20 | def info(ctx: Context, tag, json: bool): 21 | """Show a hashtag and its associated information""" 22 | tag = api.find_tag(ctx.app, ctx.user, tag) 23 | 24 | if not tag: 25 | raise click.ClickException("Tag not found") 26 | 27 | if json: 28 | click.echo(pyjson.dumps(tag)) 29 | else: 30 | tag = from_dict(Tag, tag) 31 | click.secho(f"#{tag.name}", fg="yellow") 32 | click.secho(tag.url, italic=True) 33 | if tag.following: 34 | click.echo("Followed") 35 | else: 36 | click.echo("Not followed") 37 | 38 | 39 | @tags.command() 40 | @json_option 41 | @pass_context 42 | def followed(ctx: Context, json: bool): 43 | """List followed tags""" 44 | tags = api.followed_tags(ctx.app, ctx.user) 45 | if json: 46 | click.echo(pyjson.dumps(tags)) 47 | else: 48 | if tags: 49 | print_tag_list(tags) 50 | else: 51 | click.echo("You're not following any hashtags") 52 | 53 | 54 | @tags.command() 55 | @click.argument("tag") 56 | @json_option 57 | @pass_context 58 | def follow(ctx: Context, tag: str, json: bool): 59 | """Follow a hashtag""" 60 | tag = tag.lstrip("#") 61 | response = api.follow_tag(ctx.app, ctx.user, tag) 62 | if json: 63 | click.echo(response.text) 64 | else: 65 | click.secho(f"✓ You are now following #{tag}", fg="green") 66 | 67 | 68 | @tags.command() 69 | @click.argument("tag") 70 | @json_option 71 | @pass_context 72 | def unfollow(ctx: Context, tag: str, json: bool): 73 | """Unfollow a hashtag""" 74 | tag = tag.lstrip("#") 75 | response = api.unfollow_tag(ctx.app, ctx.user, tag) 76 | if json: 77 | click.echo(response.text) 78 | else: 79 | click.secho(f"✓ You are no longer following #{tag}", fg="green") 80 | 81 | 82 | @tags.command() 83 | @json_option 84 | @pass_context 85 | def featured(ctx: Context, json: bool): 86 | """List hashtags featured on your profile.""" 87 | response = api.featured_tags(ctx.app, ctx.user) 88 | if json: 89 | click.echo(response.text) 90 | else: 91 | tags = response.json() 92 | if tags: 93 | print_tag_list(tags) 94 | else: 95 | click.echo("You don't have any featured hashtags") 96 | 97 | 98 | @tags.command() 99 | @click.argument("tag") 100 | @json_option 101 | @pass_context 102 | def feature(ctx: Context, tag: str, json: bool): 103 | """Feature a hashtag on your profile""" 104 | tag = tag.lstrip("#") 105 | response = api.feature_tag(ctx.app, ctx.user, tag) 106 | if json: 107 | click.echo(response.text) 108 | else: 109 | click.secho(f"✓ Tag #{tag} is now featured", fg="green") 110 | 111 | 112 | @tags.command() 113 | @click.argument("tag") 114 | @json_option 115 | @pass_context 116 | def unfeature(ctx: Context, tag: str, json: bool): 117 | """Unfollow a hashtag 118 | 119 | TAG can either be a tag name like "#foo" or "foo" or a tag ID. 120 | """ 121 | featured_tag = api.find_featured_tag(ctx.app, ctx.user, tag) 122 | 123 | # TODO: should this be idempotent? 124 | if not featured_tag: 125 | raise click.ClickException(f"Tag {tag} is not featured") 126 | 127 | response = api.unfeature_tag(ctx.app, ctx.user, featured_tag["id"]) 128 | if json: 129 | click.echo(response.text) 130 | else: 131 | click.secho(f"✓ Tag #{featured_tag['name']} is no longer featured", fg="green") 132 | -------------------------------------------------------------------------------- /toot/cli/diag.py: -------------------------------------------------------------------------------- 1 | import json 2 | import platform 3 | from os import path 4 | from typing import Optional 5 | 6 | import click 7 | 8 | from toot import __version__, api, config, settings 9 | from toot.cli import cli 10 | from toot.entities import Instance, from_dict 11 | from toot.output import bold, yellow 12 | from toot.utils import get_distro_name, get_version 13 | 14 | DIAG_DEPENDENCIES = [ 15 | "beautifulsoup4", 16 | "click", 17 | "pillow", 18 | "requests", 19 | "setuptools", 20 | "term-image", 21 | "tomlkit", 22 | "typing-extensions", 23 | "urwid", 24 | "urwidgets", 25 | "wcwidth", 26 | ] 27 | 28 | 29 | @cli.command() 30 | @click.option( 31 | "-f", 32 | "--files", 33 | is_flag=True, 34 | help="Print contents of the config and settings files in diagnostic output", 35 | ) 36 | @click.option( 37 | "-s", 38 | "--server", 39 | is_flag=True, 40 | help="Print information about the curren server in diagnostic output", 41 | ) 42 | def diag(files: bool, server: bool): 43 | """Display useful information for diagnosing problems""" 44 | print_diag(files, server) 45 | 46 | 47 | def print_diag(files: bool, server: bool): 48 | instance: Optional[Instance] = None 49 | if server: 50 | _, app = config.get_active_user_app() 51 | if app: 52 | response = api.get_instance(app.base_url) 53 | instance = from_dict(Instance, response.json()) 54 | 55 | click.echo("## Toot Diagnostics") 56 | print_environment() 57 | print_dependencies() 58 | print_instance(instance) 59 | print_settings(files) 60 | print_config(files) 61 | 62 | 63 | def print_environment(): 64 | click.echo() 65 | click.echo(f"toot {__version__}") 66 | click.echo(f"Python {platform.python_version()}") 67 | click.echo(platform.platform()) 68 | 69 | distro = get_distro_name() 70 | if distro: 71 | click.echo(distro) 72 | 73 | 74 | def print_dependencies(): 75 | click.echo() 76 | click.secho(bold("Dependencies:")) 77 | for dep in DIAG_DEPENDENCIES: 78 | version = get_version(dep) or yellow("not installed") 79 | click.echo(f" * {dep}: {version}") 80 | 81 | 82 | def print_instance(instance: Optional[Instance]): 83 | if instance: 84 | click.echo() 85 | click.echo(bold("Server:")) 86 | click.echo(instance.title) 87 | click.echo(instance.uri) 88 | click.echo(f"version {instance.version}") 89 | 90 | 91 | def print_settings(include_files: bool): 92 | click.echo() 93 | settings_path = settings.get_settings_path() 94 | if path.exists(settings_path): 95 | click.echo(f"Settings file: {settings_path}") 96 | if include_files: 97 | with open(settings_path, "r") as f: 98 | click.echo("\n```toml") 99 | click.echo(f.read().strip()) 100 | click.echo("```\n") 101 | else: 102 | click.echo(f'Settings file: {yellow("not found")}') 103 | 104 | 105 | def print_config(include_files: bool): 106 | click.echo() 107 | config_path = config.get_config_file_path() 108 | if path.exists(config_path): 109 | click.echo(f"Config file: {config_path}") 110 | if include_files: 111 | content = _get_anonymized_config(config_path) 112 | click.echo("\n```json") 113 | click.echo(json.dumps(content, indent=4)) 114 | click.echo("```\n") 115 | else: 116 | click.echo(f'Config file: {yellow("not found")}') 117 | 118 | 119 | def _get_anonymized_config(config_path): 120 | with open(config_path, "r") as f: 121 | content = json.load(f) 122 | 123 | for app in content.get("apps", {}).values(): 124 | app["client_id"] = "*****" 125 | app["client_secret"] = "*****" 126 | 127 | for user in content.get("users", {}).values(): 128 | user["access_token"] = "*****" 129 | 130 | return content 131 | -------------------------------------------------------------------------------- /toot/config.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | from contextlib import contextmanager 5 | from os.path import dirname, join 6 | from typing import Optional 7 | 8 | from toot import User, App, get_config_dir 9 | from toot.exceptions import ConsoleError 10 | 11 | 12 | TOOT_CONFIG_FILE_NAME = "config.json" 13 | 14 | 15 | def get_config_file_path(): 16 | """Returns the path to toot config file.""" 17 | return join(get_config_dir(), TOOT_CONFIG_FILE_NAME) 18 | 19 | 20 | def user_id(user: User): 21 | return "{}@{}".format(user.username, user.instance) 22 | 23 | 24 | def make_config(path: str): 25 | """Creates an empty toot configuration file.""" 26 | config = { 27 | "apps": {}, 28 | "users": {}, 29 | "active_user": None, 30 | } 31 | 32 | # Ensure dir exists 33 | os.makedirs(dirname(path), exist_ok=True) 34 | 35 | # Create file with 600 permissions since it contains secrets 36 | fd = os.open(path, os.O_CREAT | os.O_WRONLY, 0o600) 37 | with os.fdopen(fd, 'w') as f: 38 | json.dump(config, f, indent=True) 39 | 40 | 41 | def load_config(): 42 | # Just to prevent accidentally running tests on production 43 | if os.environ.get("TOOT_TESTING"): 44 | raise Exception("Tests should not access the config file!") 45 | 46 | path = get_config_file_path() 47 | 48 | if not os.path.exists(path): 49 | make_config(path) 50 | 51 | with open(path) as f: 52 | return json.load(f) 53 | 54 | 55 | def save_config(config): 56 | path = get_config_file_path() 57 | with open(path, "w") as f: 58 | return json.dump(config, f, indent=True, sort_keys=True) 59 | 60 | 61 | def extract_user_app(config, user_id: str): 62 | if user_id not in config['users']: 63 | return None, None 64 | 65 | user_data = config['users'][user_id] 66 | instance = user_data['instance'] 67 | 68 | if instance not in config['apps']: 69 | return None, None 70 | 71 | app_data = config['apps'][instance] 72 | return User(**user_data), App(**app_data) 73 | 74 | 75 | def get_active_user_app(): 76 | """Returns (User, App) of active user or (None, None) if no user is active.""" 77 | config = load_config() 78 | 79 | if config['active_user']: 80 | return extract_user_app(config, config['active_user']) 81 | 82 | return None, None 83 | 84 | 85 | def get_user_app(user_id: str): 86 | """Returns (User, App) for given user ID or (None, None) if user is not logged in.""" 87 | return extract_user_app(load_config(), user_id) 88 | 89 | 90 | def load_app(instance: str) -> Optional[App]: 91 | config = load_config() 92 | if instance in config['apps']: 93 | return App(**config['apps'][instance]) 94 | 95 | 96 | def load_user(user_id: str, throw=False): 97 | config = load_config() 98 | 99 | if user_id in config['users']: 100 | return User(**config['users'][user_id]) 101 | 102 | if throw: 103 | raise ConsoleError("User '{}' not found".format(user_id)) 104 | 105 | 106 | def get_user_list(): 107 | config = load_config() 108 | return config['users'] 109 | 110 | 111 | @contextmanager 112 | def edit_config(): 113 | config = load_config() 114 | yield config 115 | save_config(config) 116 | 117 | 118 | def save_app(app: App): 119 | with edit_config() as config: 120 | config['apps'][app.instance] = app._asdict() 121 | 122 | 123 | def delete_app(config, app: App): 124 | with edit_config() as config: 125 | config['apps'].pop(app.instance, None) 126 | 127 | 128 | def save_user(user: User, activate=True): 129 | with edit_config() as config: 130 | config['users'][user_id(user)] = user._asdict() 131 | 132 | if activate: 133 | config['active_user'] = user_id(user) 134 | 135 | 136 | def delete_user(user: User): 137 | with edit_config() as config: 138 | config['users'].pop(user_id(user), None) 139 | 140 | if config['active_user'] == user_id(user): 141 | config['active_user'] = None 142 | 143 | 144 | def activate_user(user: User): 145 | with edit_config() as config: 146 | config['active_user'] = user_id(user) 147 | -------------------------------------------------------------------------------- /toot/tui/images.py: -------------------------------------------------------------------------------- 1 | import urwid 2 | import math 3 | import requests 4 | import warnings 5 | 6 | # If term_image is loaded use their screen implementation which handles images 7 | try: 8 | from term_image.widget import UrwidImageScreen, UrwidImage 9 | from term_image.image import BaseImage, KittyImage, ITerm2Image, BlockImage 10 | from term_image import disable_queries # prevent phantom keystrokes 11 | from PIL import Image, ImageDraw 12 | 13 | _IMAGE_PIXEL_FORMATS = frozenset({'kitty', 'iterm'}) 14 | _ImageCls = None 15 | 16 | TuiScreen = UrwidImageScreen 17 | disable_queries() 18 | 19 | def image_support_enabled(): 20 | return True 21 | 22 | def can_render_pixels(image_format): 23 | return image_format in _IMAGE_PIXEL_FORMATS 24 | 25 | def get_base_image(image, image_format, colors) -> BaseImage: 26 | # we don't autodetect kitty, iterm; we choose based on option switches 27 | 28 | global _ImageCls 29 | 30 | if not _ImageCls: 31 | _ImageCls = ( 32 | KittyImage 33 | if image_format == 'kitty' 34 | else ITerm2Image 35 | if image_format == 'iterm' 36 | else BlockImage 37 | ) 38 | _ImageCls.forced_support = True 39 | if colors == 256 and not can_render_pixels(image_format): 40 | _ImageCls.set_render_method("INDEXED") 41 | 42 | return _ImageCls(image) 43 | 44 | def resize_image(basewidth: int, baseheight: int, img: Image.Image) -> Image.Image: 45 | if baseheight and not basewidth: 46 | hpercent = baseheight / float(img.size[1]) 47 | width = math.ceil(img.size[0] * hpercent) 48 | img = img.resize((width, baseheight), Image.Resampling.LANCZOS) 49 | elif basewidth and not baseheight: 50 | wpercent = (basewidth / float(img.size[0])) 51 | hsize = int((float(img.size[1]) * float(wpercent))) 52 | img = img.resize((basewidth, hsize), Image.Resampling.LANCZOS) 53 | else: 54 | img = img.resize((basewidth, baseheight), Image.Resampling.LANCZOS) 55 | 56 | if img.mode != 'P': 57 | img = img.convert('RGB') 58 | return img 59 | 60 | def add_corners(img, rad): 61 | circle = Image.new('L', (rad * 2, rad * 2), 0) 62 | draw = ImageDraw.Draw(circle) 63 | draw.ellipse((0, 0, rad * 2, rad * 2), fill=255) 64 | alpha = Image.new('L', img.size, "white") 65 | w, h = img.size 66 | alpha.paste(circle.crop((0, 0, rad, rad)), (0, 0)) 67 | alpha.paste(circle.crop((0, rad, rad, rad * 2)), (0, h - rad)) 68 | alpha.paste(circle.crop((rad, 0, rad * 2, rad)), (w - rad, 0)) 69 | alpha.paste(circle.crop((rad, rad, rad * 2, rad * 2)), (w - rad, h - rad)) 70 | img.putalpha(alpha) 71 | return img 72 | 73 | def load_image(url): 74 | with warnings.catch_warnings(): 75 | warnings.simplefilter("ignore") # suppress "corrupt exif" output from PIL 76 | try: 77 | img = Image.open(requests.get(url, stream=True).raw) 78 | if img.format == 'PNG' and img.mode != 'RGBA': 79 | img = img.convert("RGBA") 80 | return img 81 | except Exception: 82 | return None 83 | 84 | def graphics_widget(img, image_format="block", corner_radius=0, colors=16777216) -> urwid.Widget: 85 | if not img: 86 | return urwid.SolidFill(fill_char=" ") 87 | 88 | if can_render_pixels(image_format) and corner_radius > 0: 89 | render_img = add_corners(img, 10) 90 | else: 91 | render_img = img 92 | 93 | return UrwidImage(get_base_image(render_img, image_format, colors), '<', upscale=True) 94 | # "<" means left-justify the image 95 | 96 | except ImportError: 97 | from urwid.raw_display import Screen 98 | TuiScreen = Screen 99 | 100 | def image_support_enabled(): 101 | return False 102 | 103 | def can_render_pixels(image_format: str): 104 | return False 105 | 106 | def get_base_image(image, image_format: str): 107 | return None 108 | 109 | def add_corners(img, rad): 110 | return None 111 | 112 | def load_image(url): 113 | return None 114 | 115 | def graphics_widget(img, image_format="block", corner_radius=0) -> urwid.Widget: 116 | return urwid.SolidFill(fill_char=" ") 117 | -------------------------------------------------------------------------------- /toot/utils/language.py: -------------------------------------------------------------------------------- 1 | # Languages mapped by their ISO 639-1 code 2 | # https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes 3 | LANGUAGES = { 4 | "ab": "Abkhazian", 5 | "aa": "Afar", 6 | "af": "Afrikaans", 7 | "ak": "Akan", 8 | "sq": "Albanian", 9 | "am": "Amharic", 10 | "ar": "Arabic", 11 | "an": "Aragonese", 12 | "hy": "Armenian", 13 | "as": "Assamese", 14 | "av": "Avaric", 15 | "ae": "Avestan", 16 | "ay": "Aymara", 17 | "az": "Azerbaijani", 18 | "bm": "Bambara", 19 | "ba": "Bashkir", 20 | "eu": "Basque", 21 | "be": "Belarusian", 22 | "bn": "Bengali", 23 | "bi": "Bislama", 24 | "bs": "Bosnian", 25 | "br": "Breton", 26 | "bg": "Bulgarian", 27 | "my": "Burmese", 28 | "ca": "Catalan", 29 | "ch": "Chamorro", 30 | "ce": "Chechen", 31 | "ny": "Chichewa", 32 | "zh": "Chinese", 33 | "cu": "Old Slavonic", 34 | "cv": "Chuvash", 35 | "kw": "Cornish", 36 | "co": "Corsican", 37 | "cr": "Cree", 38 | "hr": "Croatian", 39 | "cs": "Czech", 40 | "da": "Danish", 41 | "dv": "Divehi", 42 | "nl": "Dutch", 43 | "en": "English", 44 | "eo": "Estonian", 45 | "ee": "Ewe", 46 | "fo": "Faroese", 47 | "fj": "Fijian", 48 | "fi": "Finnish", 49 | "fr": "French", 50 | "fy": "Western Frisian", 51 | "ff": "Fulah", 52 | "gd": "Gaelic", 53 | "gl": "Galician", 54 | "lg": "Ganda", 55 | "ka": "Georgian", 56 | "de": "German", 57 | "el": "Greek", 58 | "kl": "Kalaallisut", 59 | "gn": "Guarani", 60 | "gu": "Gujarati", 61 | "ht": "Haitian", 62 | "ha": "Hausa", 63 | "he": "Hebrew", 64 | "hz": "Herero", 65 | "hi": "Hiri Motu", 66 | "hu": "Hungarian", 67 | "is": "Icelandic", 68 | "io": "Ido", 69 | "ig": "Igbo", 70 | "id": "Indonesian", 71 | "ia": "Inupiaq", 72 | "ga": "Irish", 73 | "it": "Italian", 74 | "ja": "Japanese", 75 | "jv": "Javanese", 76 | "kn": "Kannada", 77 | "kr": "Kanuri", 78 | "ks": "Kashmiri", 79 | "kk": "Kazakh", 80 | "km": "Central Khmer", 81 | "ki": "Kikuyu", 82 | "rw": "Kirghiz", 83 | "kv": "Komi", 84 | "kg": "Kongo", 85 | "ko": "Korean", 86 | "kj": "Kuanyama", 87 | "ku": "Kurdish", 88 | "lo": "Lao", 89 | "la": "Latvian", 90 | "li": "Limburgan", 91 | "ln": "Lingala", 92 | "lt": "Lithuanian", 93 | "lu": "Luba-Katanga", 94 | "lb": "Luxembourgish", 95 | "mk": "Macedonian", 96 | "mg": "Malagasy", 97 | "ms": "Malay", 98 | "ml": "Malayalam", 99 | "mt": "Maltese", 100 | "gv": "Manx", 101 | "mi": "Maori", 102 | "mr": "Marathi", 103 | "mh": "Marshallese", 104 | "mn": "Mongolian", 105 | "na": "Nauru", 106 | "nv": "Navajo", 107 | "nd": "North Ndebele", 108 | "nr": "South Ndebele", 109 | "ng": "Nepali", 110 | "no": "Norwegian", 111 | "nb": "Norwegian Bokmål", 112 | "nn": "Norwegian Nynorsk", 113 | "ii": "Sichuan Yi", 114 | "oc": "Occitan", 115 | "oj": "Ojibwa", 116 | "or": "Oriya", 117 | "om": "Oromo", 118 | "os": "Ossetian", 119 | "pi": "Pali", 120 | "ps": "Pashto", 121 | "fa": "Persian", 122 | "pl": "Polish", 123 | "pt": "Portuguese", 124 | "pa": "Punjabi", 125 | "qu": "Quechua", 126 | "ro": "Romanian", 127 | "rm": "Romansh", 128 | "rn": "Rundi", 129 | "ru": "Russian", 130 | "se": "Samoan", 131 | "sg": "Sango", 132 | "sa": "Sardinian", 133 | "sr": "Serbian", 134 | "sn": "Shona", 135 | "sd": "Sindhi", 136 | "si": "Sinhala", 137 | "sk": "Slovak", 138 | "sl": "Slovenian", 139 | "so": "Somali", 140 | "st": "Southern Sotho", 141 | "es": "Spanish", 142 | "su": "Sundanese", 143 | "sw": "Swahili", 144 | "ss": "Swati", 145 | "sv": "Swedish", 146 | "tl": "Tagalog", 147 | "ty": "Tahitian", 148 | "tg": "Tajik", 149 | "ta": "Tamil", 150 | "tt": "Tatar", 151 | "te": "Telugu", 152 | "th": "Thai", 153 | "bo": "Tibetan", 154 | "ti": "Tigrinya", 155 | "to": "Tonga", 156 | "ts": "Tsonga", 157 | "tn": "Tswana", 158 | "tr": "Turkish", 159 | "tk": "Turkmen", 160 | "tw": "Uighur", 161 | "uk": "Ukrainian", 162 | "ur": "Uzbek", 163 | "ve": "Venda", 164 | "vi": "Vietnamese", 165 | "vo": "Walloon", 166 | "cy": "Welsh", 167 | "wo": "Wolof", 168 | "xh": "Xhosa", 169 | "yi": "Yiddish", 170 | "yo": "Yoruba", 171 | "za": "Zhuang", 172 | "zu": "Zulu", 173 | } 174 | 175 | 176 | def language_name(code: str) -> str: 177 | return LANGUAGES.get(code, code) 178 | -------------------------------------------------------------------------------- /toot/cli/statuses.py: -------------------------------------------------------------------------------- 1 | from copy import copy 2 | import click 3 | 4 | from toot import api 5 | from toot.cli import cli, json_option, Context, pass_context 6 | from toot.cli import VISIBILITY_CHOICES 7 | from toot.output import print_table 8 | 9 | 10 | @cli.command() 11 | @click.argument("status_id") 12 | @json_option 13 | @pass_context 14 | def delete(ctx: Context, status_id: str, json: bool): 15 | """Delete a status""" 16 | response = api.delete_status(ctx.app, ctx.user, status_id) 17 | if json: 18 | click.echo(response.text) 19 | else: 20 | click.secho("✓ Status deleted", fg="green") 21 | 22 | 23 | @cli.command() 24 | @click.argument("status_id") 25 | @json_option 26 | @pass_context 27 | def favourite(ctx: Context, status_id: str, json: bool): 28 | """Favourite a status""" 29 | response = api.favourite(ctx.app, ctx.user, status_id) 30 | if json: 31 | click.echo(response.text) 32 | else: 33 | click.secho("✓ Status favourited", fg="green") 34 | 35 | 36 | @cli.command() 37 | @click.argument("status_id") 38 | @json_option 39 | @pass_context 40 | def unfavourite(ctx: Context, status_id: str, json: bool): 41 | """Unfavourite a status""" 42 | response = api.unfavourite(ctx.app, ctx.user, status_id) 43 | if json: 44 | click.echo(response.text) 45 | else: 46 | click.secho("✓ Status unfavourited", fg="green") 47 | 48 | 49 | @cli.command() 50 | @click.argument("status_id") 51 | @click.option( 52 | "--visibility", "-v", 53 | help="Post visibility", 54 | type=click.Choice(VISIBILITY_CHOICES), 55 | default="public", 56 | ) 57 | @json_option 58 | @pass_context 59 | def reblog(ctx: Context, status_id: str, visibility: str, json: bool): 60 | """Reblog (boost) a status""" 61 | response = api.reblog(ctx.app, ctx.user, status_id, visibility=visibility) 62 | if json: 63 | click.echo(response.text) 64 | else: 65 | click.secho("✓ Status reblogged", fg="green") 66 | 67 | 68 | @cli.command() 69 | @click.argument("status_id") 70 | @json_option 71 | @pass_context 72 | def unreblog(ctx: Context, status_id: str, json: bool): 73 | """Unreblog (unboost) a status""" 74 | response = api.unreblog(ctx.app, ctx.user, status_id) 75 | if json: 76 | click.echo(response.text) 77 | else: 78 | click.secho("✓ Status unreblogged", fg="green") 79 | 80 | 81 | @cli.command() 82 | @click.argument("status_id") 83 | @json_option 84 | @pass_context 85 | def pin(ctx: Context, status_id: str, json: bool): 86 | """Pin a status""" 87 | response = api.pin(ctx.app, ctx.user, status_id) 88 | if json: 89 | click.echo(response.text) 90 | else: 91 | click.secho("✓ Status pinned", fg="green") 92 | 93 | 94 | @cli.command() 95 | @click.argument("status_id") 96 | @json_option 97 | @pass_context 98 | def unpin(ctx: Context, status_id: str, json: bool): 99 | """Unpin a status""" 100 | response = api.unpin(ctx.app, ctx.user, status_id) 101 | if json: 102 | click.echo(response.text) 103 | else: 104 | click.secho("✓ Status unpinned", fg="green") 105 | 106 | 107 | @cli.command() 108 | @click.argument("status_id") 109 | @json_option 110 | @pass_context 111 | def bookmark(ctx: Context, status_id: str, json: bool): 112 | """Bookmark a status""" 113 | response = api.bookmark(ctx.app, ctx.user, status_id) 114 | if json: 115 | click.echo(response.text) 116 | else: 117 | click.secho("✓ Status bookmarked", fg="green") 118 | 119 | 120 | @cli.command() 121 | @click.argument("status_id") 122 | @json_option 123 | @pass_context 124 | def unbookmark(ctx: Context, status_id: str, json: bool): 125 | """Unbookmark a status""" 126 | response = api.unbookmark(ctx.app, ctx.user, status_id) 127 | if json: 128 | click.echo(response.text) 129 | else: 130 | click.secho("✓ Status unbookmarked", fg="green") 131 | 132 | 133 | @cli.command() 134 | @click.argument("status_id") 135 | @json_option 136 | @pass_context 137 | def reblogged_by(ctx: Context, status_id: str, json: bool): 138 | """Show accounts that reblogged a status""" 139 | response = api.reblogged_by(ctx.app, ctx.user, status_id) 140 | 141 | if json: 142 | click.echo(response.text) 143 | else: 144 | rows = [[a["acct"], a["display_name"]] for a in response.json()] 145 | if rows: 146 | headers = ["Account", "Display name"] 147 | print_table(headers, rows) 148 | else: 149 | click.echo("This status is not reblogged by anyone") 150 | 151 | 152 | # Make alias in snake case to keep BC 153 | reblogged_by_alias = copy(reblogged_by) 154 | reblogged_by_alias.hidden = True 155 | cli.add_command(reblogged_by_alias, name="reblogged_by") 156 | -------------------------------------------------------------------------------- /toot/tui/utils.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import re 3 | import sys 4 | import urwid 5 | from collections import OrderedDict 6 | from functools import reduce 7 | from html.parser import HTMLParser 8 | from typing import List 9 | 10 | HASHTAG_PATTERN = re.compile(r'(?>> highlight_keys("[P]rint [V]iew", "blue") 23 | >>> [('blue', 'P'), 'rint ', ('blue', 'V'), 'iew'] 24 | """ 25 | def _gen(): 26 | highlighted = False 27 | for part in re.split("\\[|\\]", text): 28 | if part: 29 | if highlighted: 30 | yield (high_attr, part) if high_attr else part 31 | else: 32 | yield (low_attr, part) if low_attr else part 33 | highlighted = not highlighted 34 | return list(_gen()) 35 | 36 | 37 | def highlight_hashtags(line): 38 | hline = [] 39 | 40 | for p in re.split(HASHTAG_PATTERN, line): 41 | if p.startswith("#"): 42 | hline.append(("hashtag", p)) 43 | else: 44 | hline.append(p) 45 | 46 | return hline 47 | 48 | 49 | class LinkParser(HTMLParser): 50 | def reset(self): 51 | super().reset() 52 | self.links = [] 53 | 54 | def handle_starttag(self, tag, attrs): 55 | if tag == "a": 56 | href, title = None, None 57 | for name, value in attrs: 58 | if name == "href": 59 | href = value 60 | if name == "title": 61 | title = value 62 | if href: 63 | self.links.append((href, title)) 64 | 65 | 66 | def parse_content_links(content): 67 | """Parse tags from status's `content` and return them as a list of 68 | (href, title), where `title` may be None. 69 | """ 70 | parser = LinkParser() 71 | parser.feed(content) 72 | return parser.links[:] 73 | 74 | 75 | def copy_to_clipboard(screen: urwid.raw_display.Screen, text: str): 76 | """ copy text to clipboard using OSC 52 77 | This escape sequence is documented 78 | here https://iterm2.com/documentation-escape-codes.html 79 | It has wide support - XTerm, Windows Terminal, 80 | Kitty, iTerm2, others. Some terminals may require a setting to be 81 | enabled in order to use OSC 52 clipboard functions. 82 | """ 83 | 84 | text_bytes = text.encode("utf-8") 85 | b64_bytes = base64.b64encode(text_bytes) 86 | b64_text = b64_bytes.decode("utf-8") 87 | 88 | screen.write(f"\033]52;c;{b64_text}\a") 89 | screen.flush() 90 | 91 | 92 | def get_max_toot_chars(instance, default=500): 93 | # Mastodon 94 | # https://docs.joinmastodon.org/entities/Instance/#max_characters 95 | max_toot_chars = deep_get(instance, ["configuration", "statuses", "max_characters"]) 96 | if isinstance(max_toot_chars, int): 97 | return max_toot_chars 98 | 99 | # Pleroma 100 | max_toot_chars = instance.get("max_toot_chars") 101 | if isinstance(max_toot_chars, int): 102 | return max_toot_chars 103 | 104 | return default 105 | 106 | 107 | def deep_get(adict: dict, path: List[str], default=None): 108 | return reduce( 109 | lambda d, key: d.get(key, default) if isinstance(d, dict) else default, 110 | path, 111 | adict 112 | ) 113 | 114 | 115 | class LRUCache(OrderedDict): 116 | """Dict with a limited size, ejecting LRUs as needed. 117 | Default max size = 10Mb""" 118 | 119 | def __init__(self, *args, cache_max_bytes: int = 1024 * 1024 * 10, **kwargs): 120 | assert cache_max_bytes > 0 121 | self.total_value_size = 0 122 | self.cache_max_bytes = cache_max_bytes 123 | 124 | super().__init__(*args, **kwargs) 125 | 126 | def __setitem__(self, key: str, value): 127 | if key in self: 128 | self.total_value_size -= sys.getsizeof(super().__getitem__(key).tobytes()) 129 | self.total_value_size += sys.getsizeof(value.tobytes()) 130 | super().__setitem__(key, value) 131 | super().move_to_end(key) 132 | 133 | while self.total_value_size > self.cache_max_bytes: 134 | old_key, value = next(iter(self.items())) 135 | sz = sys.getsizeof(value.tobytes()) 136 | super().__delitem__(old_key) 137 | self.total_value_size -= sz 138 | 139 | def __getitem__(self, key: str): 140 | val = super().__getitem__(key) 141 | super().move_to_end(key) 142 | return val 143 | -------------------------------------------------------------------------------- /toot/cli/read.py: -------------------------------------------------------------------------------- 1 | import click 2 | import json as pyjson 3 | 4 | from itertools import chain 5 | from typing import Optional 6 | 7 | from toot import api 8 | from toot.cli.validators import validate_instance 9 | from toot.entities import Instance, Status, from_dict, Account 10 | from toot.exceptions import ApiError, ConsoleError 11 | from toot.output import print_account, print_instance, print_search_results, print_status, print_timeline 12 | from toot.cli import InstanceParamType, cli, get_context, json_option, pass_context, Context 13 | 14 | 15 | @cli.command() 16 | @json_option 17 | @pass_context 18 | def whoami(ctx: Context, json: bool): 19 | """Display logged in user details""" 20 | response = api.verify_credentials(ctx.app, ctx.user) 21 | 22 | if json: 23 | click.echo(response.text) 24 | else: 25 | account = from_dict(Account, response.json()) 26 | print_account(account) 27 | 28 | 29 | @cli.command() 30 | @click.argument("account") 31 | @json_option 32 | @pass_context 33 | def whois(ctx: Context, account: str, json: bool): 34 | """Display account details""" 35 | account_dict = api.find_account(ctx.app, ctx.user, account) 36 | 37 | # Here it's not possible to avoid parsing json since it's needed to find the account. 38 | if json: 39 | click.echo(pyjson.dumps(account_dict)) 40 | else: 41 | account_obj = from_dict(Account, account_dict) 42 | print_account(account_obj) 43 | 44 | 45 | @cli.command() 46 | @click.argument("instance", type=InstanceParamType(), callback=validate_instance, required=False) 47 | @json_option 48 | def instance(instance: Optional[str], json: bool): 49 | """Display instance details 50 | 51 | INSTANCE can be a domain or base URL of the instance to display. 52 | e.g. 'mastodon.social' or 'https://mastodon.social'. If not 53 | given will display details for the currently logged in instance. 54 | """ 55 | if not instance: 56 | context = get_context() 57 | if not context.app: 58 | raise click.ClickException("INSTANCE argument not given and not logged in") 59 | instance = context.app.base_url 60 | 61 | try: 62 | response = api.get_instance(instance) 63 | except ApiError: 64 | raise ConsoleError( 65 | f"Instance not found at {instance}.\n" + 66 | "The given domain probably does not host a Mastodon instance." 67 | ) 68 | 69 | if json: 70 | click.echo(response.text) 71 | else: 72 | print_instance(from_dict(Instance, response.json())) 73 | 74 | 75 | @cli.command() 76 | @click.argument("query") 77 | @click.option("-r", "--resolve", is_flag=True, help="Resolve non-local accounts") 78 | @click.option( 79 | "-t", "--type", 80 | type=click.Choice(["accounts", "hashtags", "statuses"]), 81 | help="Limit search to one type only" 82 | ) 83 | @click.option("-o", "--offset", type=int, help="Return results starting from (default 0)") 84 | @click.option("-l", "--limit", type=int, help="Maximum number of results to return, per type. (default 20, max 40)") 85 | @click.option("--min-id", help="Return results newer than this ID.") 86 | @click.option("--max-id", help="Return results older than this ID.") 87 | @json_option 88 | @pass_context 89 | def search( 90 | ctx: Context, 91 | query: str, 92 | resolve: bool, 93 | type: Optional[str], 94 | offset: Optional[int], 95 | limit: Optional[int], 96 | min_id: Optional[str], 97 | max_id: Optional[str], 98 | json: bool 99 | ): 100 | """Search for content in accounts, statuses and hashtags.""" 101 | response = api.search(ctx.app, ctx.user, query, resolve, type, offset, limit, min_id, max_id) 102 | if json: 103 | click.echo(response.text) 104 | else: 105 | print_search_results(response.json()) 106 | 107 | 108 | @cli.command() 109 | @click.argument("status_id") 110 | @json_option 111 | @pass_context 112 | def status(ctx: Context, status_id: str, json: bool): 113 | """Show a single status""" 114 | response = api.fetch_status(ctx.app, ctx.user, status_id) 115 | if json: 116 | click.echo(response.text) 117 | else: 118 | status = from_dict(Status, response.json()) 119 | print_status(status) 120 | 121 | 122 | @cli.command() 123 | @click.argument("status_id") 124 | @json_option 125 | @pass_context 126 | def thread(ctx: Context, status_id: str, json: bool): 127 | """Show thread for a toot.""" 128 | context_response = api.context(ctx.app, ctx.user, status_id) 129 | if json: 130 | click.echo(context_response.text) 131 | else: 132 | toot = api.fetch_status(ctx.app, ctx.user, status_id).json() 133 | context = context_response.json() 134 | 135 | statuses = chain(context["ancestors"], [toot], context["descendants"]) 136 | print_timeline(from_dict(Status, s) for s in statuses) 137 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | Usage 2 | ===== 3 | 4 | Running `toot` displays a list of available commands. 5 | 6 | Running `toot -h` shows the documentation for the given command. 7 | 8 | Below is an overview of some common scenarios. 9 | 10 | 11 | 12 | Authentication 13 | -------------- 14 | 15 | Before tooting, you need to log into a Mastodon instance. 16 | 17 | toot login 18 | 19 | You will be redirected to your Mastodon instance to log in and authorize toot to 20 | access your account, and will be given an **authorization code** in return 21 | which you need to enter to log in. 22 | 23 | The application and user access tokens will be saved in the configuration file 24 | located at `~/.config/toot/config.json`. 25 | 26 | ### Using multiple accounts 27 | 28 | It's possible to be logged into multiple accounts at the same time. Just 29 | repeat the login process for another instance. You can see all logged in 30 | accounts by running `toot auth`. The currently active account will have an 31 | **ACTIVE** flag next to it. 32 | 33 | To switch accounts, use `toot activate`. Alternatively, most commands accept a 34 | `--using` option which can be used to specify the account you wish to use just 35 | that one time. 36 | 37 | Finally you can logout from an account by using `toot logout`. This will 38 | remove the stored access tokens for that account. 39 | 40 | Post a status 41 | ------------- 42 | 43 | The simplest action is posting a status. 44 | 45 | ```sh 46 | toot post "hello there" 47 | ``` 48 | 49 | You can also pipe in the status text: 50 | 51 | ```sh 52 | echo "Text to post" | toot post 53 | cat post.txt | toot post 54 | toot post < post.txt 55 | ``` 56 | 57 | If no status text is given, you will be prompted to enter some: 58 | 59 | ```sh 60 | $ toot post 61 | Write or paste your toot. Press Ctrl-D to post it. 62 | ``` 63 | 64 | Finally, you can launch your favourite editor: 65 | 66 | ```sh 67 | toot post --editor vim 68 | ``` 69 | 70 | Define your editor preference in the `EDITOR` environment variable, then you 71 | don't need to specify it explicitly: 72 | 73 | ```sh 74 | export EDITOR=vim 75 | toot post --editor 76 | ``` 77 | 78 | ### Attachments 79 | 80 | You can attach media to your status. Mastodon supports images, video and audio 81 | files. For details on supported formats see 82 | [Mastodon docs on attachments](https://docs.joinmastodon.org/user/posting/#attachments). 83 | 84 | It is encouraged to add a plain-text description to the attached media for 85 | accessibility purposes by adding a `--description` option. 86 | 87 | To attach an image: 88 | 89 | ```sh 90 | toot post "hello media" --media path/to/image.png --description "Cool image" 91 | ``` 92 | 93 | You can attach upto 4 attachments by giving multiple `--media` and 94 | `--description` options: 95 | 96 | ```sh 97 | toot post "hello media" \ 98 | --media path/to/image1.png --description "First image" \ 99 | --media path/to/image2.png --description "Second image" \ 100 | --media path/to/image3.png --description "Third image" \ 101 | --media path/to/image4.png --description "Fourth image" 102 | ``` 103 | 104 | The order of options is not relevant, except that the first given media will be 105 | matched to the first given description and so on. 106 | 107 | If the media is sensitive, mark it as such and people will need to click to show 108 | it. This affects all attachments. 109 | 110 | ```sh 111 | toot post "naughty pics ahoy" --media nsfw.png --sensitive 112 | ``` 113 | 114 | View timeline 115 | ------------- 116 | 117 | View what's on your home timeline: 118 | 119 | ```sh 120 | toot timeline 121 | ``` 122 | 123 | Timeline takes various options: 124 | 125 | ```sh 126 | toot timeline --public # public timeline 127 | toot timeline --public --local # public timeline, only this instance 128 | toot timeline --tag photo # posts tagged with #photo 129 | toot timeline --count 5 # fetch 5 toots (max 20) 130 | toot timeline --once # don't prompt to fetch more toots 131 | ``` 132 | 133 | Add `--help` to see all the options. 134 | 135 | Status actions 136 | -------------- 137 | 138 | The timeline lists the status ID at the bottom of each toot. Using that status 139 | you can do various actions to it, e.g.: 140 | 141 | ```sh 142 | toot favourite 123456 143 | toot reblog 123456 144 | ``` 145 | 146 | If it's your own status you can also delete pin or delete it: 147 | 148 | ```sh 149 | toot pin 123456 150 | toot delete 123456 151 | ``` 152 | 153 | Account actions 154 | --------------- 155 | 156 | Find a user by their name or account name: 157 | 158 | ```sh 159 | toot search "name surname" 160 | toot search @someone 161 | toot search someone@someplace.social 162 | ``` 163 | 164 | Once found, follow them: 165 | 166 | ```sh 167 | toot follow someone@someplace.social 168 | ``` 169 | 170 | If you get bored of them: 171 | 172 | ```sh 173 | toot mute someone@someplace.social 174 | toot block someone@someplace.social 175 | toot unfollow someone@someplace.social 176 | ``` 177 | -------------------------------------------------------------------------------- /tests/integration/test_polls.py: -------------------------------------------------------------------------------- 1 | import json 2 | from tests.integration.conftest import Run, assert_error, assert_ok, posted_status_id, strip_ansi 3 | from toot import App, User, api, cli 4 | 5 | 6 | def test_show_poll(app: App, user: User, run: Run): 7 | result = run( 8 | cli.post.post, "Answer me this", 9 | "--poll-option", "foo", 10 | "--poll-option", "bar", 11 | "--poll-option", "baz", 12 | ) 13 | assert_ok(result) 14 | 15 | status_id = posted_status_id(result.stdout) 16 | status = api.fetch_status(app, user, status_id).json() 17 | poll_id = status["poll"]["id"] 18 | 19 | result = run(cli.polls.show, poll_id) 20 | assert_ok(result) 21 | 22 | assert "foo" in result.stdout 23 | assert "bar" in result.stdout 24 | assert "baz" in result.stdout 25 | assert f"Poll {poll_id}" in result.stdout 26 | 27 | 28 | def test_show_poll_json(app: App, user: User, run: Run): 29 | result = run( 30 | cli.post.post, "Answer me this", 31 | "--poll-option", "foo", 32 | "--poll-option", "bar", 33 | "--poll-option", "baz", 34 | ) 35 | assert_ok(result) 36 | 37 | status_id = posted_status_id(result.stdout) 38 | status = api.fetch_status(app, user, status_id).json() 39 | poll_id = status["poll"]["id"] 40 | 41 | result = run(cli.polls.show, poll_id, "--json") 42 | assert_ok(result) 43 | 44 | poll = json.loads(result.stdout) 45 | assert poll["id"] == poll_id 46 | assert poll["options"] == [ 47 | {"title": "foo", "votes_count": 0}, 48 | {"title": "bar", "votes_count": 0}, 49 | {"title": "baz", "votes_count": 0}, 50 | ] 51 | assert poll["multiple"] is False 52 | 53 | 54 | def test_vote_poll(app: App, user: User, friend: User, run: Run, run_as: Run): 55 | result = run( 56 | cli.post.post, 57 | "Answer me this", 58 | "--poll-option", "foo", 59 | "--poll-option", "bar", 60 | "--poll-option", "baz", 61 | ) 62 | assert_ok(result) 63 | 64 | status_id = posted_status_id(result.stdout) 65 | status = api.fetch_status(app, user, status_id).json() 66 | poll_id = status["poll"]["id"] 67 | 68 | result = run_as(friend, cli.polls.vote, poll_id, "0") 69 | assert_ok(result) 70 | 71 | output_lines = strip_ansi(result.stdout).split("\n") 72 | assert "foo ✓ Your vote" in output_lines 73 | assert "bar" in output_lines 74 | assert "baz" in output_lines 75 | 76 | # Voting a second time should not succeed 77 | result = run_as(friend, cli.polls.vote, poll_id, "0") 78 | assert_error(result, "You have already voted on this poll") 79 | 80 | 81 | def test_vote_poll_invalid_choice(app: App, user: User, friend: User, run: Run, run_as: Run): 82 | result = run( 83 | cli.post.post, 84 | "Answer me this", 85 | "--poll-option", "foo", 86 | "--poll-option", "bar", 87 | "--poll-option", "baz", 88 | ) 89 | assert_ok(result) 90 | 91 | status_id = posted_status_id(result.stdout) 92 | status = api.fetch_status(app, user, status_id).json() 93 | poll_id = status["poll"]["id"] 94 | 95 | result = run_as(friend, cli.polls.vote, poll_id, "5") # invalid index 96 | assert_error(result, "The chosen vote option does not exist") 97 | 98 | 99 | def test_vote_not_multiple(app: App, user: User, friend: User, run: Run, run_as: Run): 100 | result = run( 101 | cli.post.post, 102 | "Answer me this", 103 | "--poll-option", "foo", 104 | "--poll-option", "bar", 105 | "--poll-option", "baz", 106 | ) 107 | assert_ok(result) 108 | 109 | status_id = posted_status_id(result.stdout) 110 | status = api.fetch_status(app, user, status_id).json() 111 | poll_id = status["poll"]["id"] 112 | 113 | # Voting on multiple choices when poll is not multiple 114 | result = run_as(friend, cli.polls.vote, poll_id, "0", "2") 115 | # NB: Mastodon returns the wrong error here 116 | assert_error(result, "You have already voted on this poll") 117 | 118 | 119 | def test_vote_poll_multiple(app: App, user: User, friend: User, run: Run, run_as: Run): 120 | result = run( 121 | cli.post.post, 122 | "Answer me this", 123 | "--poll-option", "foo", 124 | "--poll-option", "bar", 125 | "--poll-option", "baz", 126 | "--poll-multiple" 127 | ) 128 | assert_ok(result) 129 | 130 | status_id = posted_status_id(result.stdout) 131 | status = api.fetch_status(app, user, status_id).json() 132 | poll_id = status["poll"]["id"] 133 | 134 | result = run_as(friend, cli.polls.vote, poll_id, "0", "2") 135 | assert_ok(result) 136 | 137 | output_lines = strip_ansi(result.stdout).split("\n") 138 | assert "foo ✓ Your vote" in output_lines 139 | assert "bar" in output_lines 140 | assert "baz ✓ Your vote" in output_lines 141 | 142 | # Voting a second time should not succeed 143 | result = run_as(friend, cli.polls.vote, poll_id, "0", "2") 144 | assert_error(result, "You have already voted on this poll") 145 | -------------------------------------------------------------------------------- /toot/cli/auth.py: -------------------------------------------------------------------------------- 1 | import webbrowser 2 | 3 | import click 4 | 5 | from toot import api, config 6 | from toot.auth import get_or_create_app, login_auth_code, login_username_password 7 | from toot.cli import AccountParamType, cli 8 | from toot.cli.diag import print_diag 9 | from toot.cli.validators import validate_instance 10 | from toot.output import print_warning 11 | 12 | instance_option = click.option( 13 | "--instance", "-i", "base_url", 14 | prompt="Enter instance URL", 15 | default="https://mastodon.social", 16 | callback=validate_instance, 17 | help="""Domain or base URL of the instance to log into, 18 | e.g. 'mastodon.social' or 'https://mastodon.social'""", 19 | ) 20 | 21 | 22 | @cli.command() 23 | def auth(): 24 | """Show logged in accounts and instances""" 25 | config_data = config.load_config() 26 | 27 | if not config_data["users"]: 28 | click.echo("You are not logged in to any accounts") 29 | return 30 | 31 | active_user = config_data["active_user"] 32 | 33 | click.echo("Authenticated accounts:") 34 | for uid, u in config_data["users"].items(): 35 | active_label = "ACTIVE" if active_user == uid else "" 36 | uid = click.style(uid, fg="green") 37 | active_label = click.style(active_label, fg="yellow") 38 | click.echo(f"* {uid} {active_label}") 39 | 40 | path = config.get_config_file_path() 41 | path = click.style(path, "blue") 42 | click.echo(f"\nAuth tokens are stored in: {path}") 43 | 44 | 45 | @cli.command(hidden=True) 46 | def env(): 47 | """Deprecated in favour of 'diag'""" 48 | print_warning("`toot env` is deprecated in favour of `toot diag`") 49 | click.echo() 50 | print_diag(False, False) 51 | 52 | 53 | @cli.command(name="login_cli") 54 | @instance_option 55 | @click.option("--email", "-e", help="Email address to log in with", prompt=True) 56 | @click.option("--password", "-p", hidden=True, prompt=True, hide_input=True) 57 | def login_cli(base_url: str, email: str, password: str): 58 | """ 59 | Log into an instance from the console (not recommended) 60 | 61 | Does NOT support two factor authentication, may not work on instances 62 | other than Mastodon, mostly useful for scripting. 63 | """ 64 | app = get_or_create_app(base_url) 65 | login_username_password(app, email, password) 66 | 67 | click.secho("✓ Successfully logged in.", fg="green") 68 | click.echo("Access token saved to config at: ", nl=False) 69 | click.secho(config.get_config_file_path(), fg="green") 70 | 71 | 72 | LOGIN_EXPLANATION = """This authentication method requires you to log into your 73 | Mastodon instance in your browser, where you will be asked to authorize toot to 74 | access your account. When you do, you will be given an authorization code which 75 | you need to paste here.""".replace("\n", " ") 76 | 77 | 78 | @cli.command() 79 | @instance_option 80 | def login(base_url: str): 81 | """Log into an instance using your browser (recommended)""" 82 | app = get_or_create_app(base_url) 83 | url = api.get_browser_login_url(app) 84 | 85 | click.echo(click.wrap_text(LOGIN_EXPLANATION)) 86 | click.echo("\nLogin URL:") 87 | click.echo(url) 88 | 89 | yesno = click.prompt("Open link in default browser? [Y/n]", default="Y", show_default=False) 90 | if not yesno or yesno.lower() == 'y': 91 | webbrowser.open(url) 92 | 93 | authorization_code = "" 94 | while not authorization_code: 95 | authorization_code = click.prompt("Authorization code") 96 | 97 | login_auth_code(app, authorization_code) 98 | 99 | click.echo() 100 | click.secho("✓ Successfully logged in.", fg="green") 101 | 102 | 103 | @cli.command() 104 | @click.argument("account", type=AccountParamType(), required=False) 105 | def logout(account: str): 106 | """Log out of ACCOUNT, delete stored access keys""" 107 | accounts = _get_accounts_list() 108 | 109 | if not account: 110 | raise click.ClickException(f"Specify account to log out:\n{accounts}") 111 | 112 | user = config.load_user(account) 113 | 114 | if not user: 115 | raise click.ClickException(f"Account not found. Logged in accounts:\n{accounts}") 116 | 117 | config.delete_user(user) 118 | click.secho(f"✓ Account {account} logged out", fg="green") 119 | 120 | 121 | @cli.command() 122 | @click.argument("account", type=AccountParamType(), required=False) 123 | def activate(account: str): 124 | """Switch to logged in ACCOUNT.""" 125 | accounts = _get_accounts_list() 126 | 127 | if not account: 128 | raise click.ClickException(f"Specify account to activate:\n{accounts}") 129 | 130 | user = config.load_user(account) 131 | 132 | if not user: 133 | raise click.ClickException(f"Account not found. Logged in accounts:\n{accounts}") 134 | 135 | config.activate_user(user) 136 | click.secho(f"✓ Account {account} activated", fg="green") 137 | 138 | 139 | def _get_accounts_list() -> str: 140 | accounts = config.load_config()["users"].keys() 141 | if not accounts: 142 | raise click.ClickException("You're not logged into any accounts") 143 | return "\n".join([f"* {acct}" for acct in accounts]) 144 | -------------------------------------------------------------------------------- /tests/integration/conftest.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module contains integration tests meant to run against a test Mastodon instance. 3 | 4 | You can set up a test instance locally by following this guide: 5 | https://docs.joinmastodon.org/dev/setup/ 6 | 7 | To enable integration tests, export the following environment variables to match 8 | your test server and database: 9 | 10 | ``` 11 | export TOOT_TEST_BASE_URL="localhost:3000" 12 | ``` 13 | """ 14 | 15 | import json 16 | import os 17 | import pytest 18 | import re 19 | import typing as t 20 | import uuid 21 | 22 | from click.testing import CliRunner, Result 23 | from pathlib import Path 24 | from toot import api, App, User 25 | from toot.cli import Context, TootObj 26 | 27 | 28 | def pytest_configure(config): 29 | import toot.settings 30 | toot.settings.DISABLE_SETTINGS = True 31 | 32 | 33 | # Type alias for run commands 34 | Run = t.Callable[..., Result] 35 | 36 | # Mastodon database name, used to confirm user registration without having to click the link 37 | TOOT_TEST_BASE_URL = os.getenv("TOOT_TEST_BASE_URL") 38 | 39 | # Toot logo used for testing image upload 40 | TRUMPET = str(Path(__file__).parent.parent.parent / "trumpet.png") 41 | 42 | ASSETS_DIR = str(Path(__file__).parent.parent / "assets") 43 | 44 | PASSWORD = "83dU29170rjKilKQQwuWhJv3PKnSW59bWx0perjP6i7Nu4rkeh4mRfYuvVLYM3fM" 45 | 46 | 47 | def create_app(base_url): 48 | instance = api.get_instance(base_url).json() 49 | response = api.create_app(base_url) 50 | return App(instance["uri"], base_url, response["client_id"], response["client_secret"]) 51 | 52 | 53 | def register_account(app: App): 54 | username = str(uuid.uuid4())[-10:] 55 | email = f"{username}@example.com" 56 | 57 | response = api.register_account(app, username, email, PASSWORD, "en") 58 | return User(app.instance, username, response["access_token"]) 59 | 60 | 61 | # ------------------------------------------------------------------------------ 62 | # Fixtures 63 | # ------------------------------------------------------------------------------ 64 | 65 | 66 | # Host name of a test instance to run integration tests against 67 | # DO NOT USE PUBLIC INSTANCES!!! 68 | @pytest.fixture(scope="session") 69 | def base_url(): 70 | if not TOOT_TEST_BASE_URL: 71 | pytest.skip("Skipping integration tests, TOOT_TEST_BASE_URL not set") 72 | 73 | return TOOT_TEST_BASE_URL 74 | 75 | 76 | @pytest.fixture(scope="session") 77 | def app(base_url): 78 | return create_app(base_url) 79 | 80 | 81 | @pytest.fixture() 82 | def user(app): 83 | return register_account(app) 84 | 85 | 86 | @pytest.fixture() 87 | def friend(app): 88 | return register_account(app) 89 | 90 | 91 | @pytest.fixture() 92 | def user_id(app, user): 93 | return api.find_account(app, user, user.username)["id"] 94 | 95 | 96 | @pytest.fixture() 97 | def friend_id(app, user, friend): 98 | return api.find_account(app, user, friend.username)["id"] 99 | 100 | 101 | @pytest.fixture(scope="session", autouse=True) 102 | def testing_env(): 103 | os.environ["TOOT_TESTING"] = "true" 104 | 105 | 106 | @pytest.fixture(scope="session") 107 | def runner(): 108 | return CliRunner() 109 | 110 | 111 | @pytest.fixture 112 | def run(app, user, runner): 113 | def _run(command, *params, input=None) -> Result: 114 | obj = TootObj(test_ctx=Context(app, user)) 115 | return runner.invoke(command, params, obj=obj, input=input) 116 | return _run 117 | 118 | 119 | @pytest.fixture 120 | def run_as(app, runner): 121 | def _run_as(user, command, *params, input=None) -> Result: 122 | obj = TootObj(test_ctx=Context(app, user)) 123 | return runner.invoke(command, params, obj=obj, input=input) 124 | return _run_as 125 | 126 | 127 | @pytest.fixture 128 | def run_json(app, user, runner): 129 | def _run_json(command, *params): 130 | obj = TootObj(test_ctx=Context(app, user)) 131 | result = runner.invoke(command, params, obj=obj) 132 | assert_ok(result) 133 | return json.loads(result.stdout) 134 | return _run_json 135 | 136 | 137 | @pytest.fixture 138 | def run_anon(runner): 139 | def _run(command, *params) -> Result: 140 | obj = TootObj(test_ctx=Context(None, None)) 141 | return runner.invoke(command, params, obj=obj) 142 | return _run 143 | 144 | 145 | # ------------------------------------------------------------------------------ 146 | # Utils 147 | # ------------------------------------------------------------------------------ 148 | 149 | 150 | def posted_status_id(out): 151 | pattern = re.compile(r"Toot posted: http://([^/]+)/([^/]+)/(.+)") 152 | match = re.search(pattern, out) 153 | assert match 154 | 155 | _, _, status_id = match.groups() 156 | 157 | return status_id 158 | 159 | 160 | def assert_ok(result: Result): 161 | if result.exit_code != 0: 162 | raise AssertionError( 163 | f"Command failed with exit code {result.exit_code}\n" 164 | f"stderr: {result.stderr}\n" 165 | f"exception: {result.exception}" 166 | ) 167 | 168 | 169 | def assert_error(result: Result, error: str): 170 | assert result.exit_code != 0 171 | assert error in strip_ansi(result.stderr) 172 | 173 | 174 | def strip_ansi(string: str): 175 | return re.sub(r"\x1b\[[0-9;]*[A-Za-z]", "", string) 176 | -------------------------------------------------------------------------------- /toot/http.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urlencode, urlparse 2 | 3 | from requests import Request, Session 4 | from requests.exceptions import RequestException 5 | 6 | from toot import __version__ 7 | from toot.exceptions import ApiError, NotFoundError 8 | from toot.logging import log_request, log_request_exception, log_response 9 | 10 | 11 | def send_request(request, allow_redirects=True): 12 | # Set a user agent string 13 | # Required for accessing instances using Cloudfront DDOS protection. 14 | request.headers["User-Agent"] = "toot/{}".format(__version__) 15 | 16 | log_request(request) 17 | 18 | try: 19 | with Session() as session: 20 | prepared = session.prepare_request(request) 21 | settings = session.merge_environment_settings(prepared.url, {}, None, None, None) 22 | response = session.send(prepared, allow_redirects=allow_redirects, **settings) 23 | except RequestException as ex: 24 | log_request_exception(request, ex) 25 | raise ApiError(f"Request failed: {str(ex)}") 26 | 27 | log_response(response) 28 | 29 | return response 30 | 31 | 32 | def _get_error_message(response): 33 | """Attempt to extract an error message from response body""" 34 | try: 35 | data = response.json() 36 | if "error_description" in data: 37 | return data['error_description'] 38 | if "error" in data: 39 | return data['error'] 40 | except Exception: 41 | pass 42 | 43 | return f"Unknown error: {response.status_code} {response.reason}" 44 | 45 | 46 | def process_response(response): 47 | if not response.ok: 48 | error = _get_error_message(response) 49 | 50 | if response.status_code == 404: 51 | raise NotFoundError(error) 52 | 53 | raise ApiError(error) 54 | 55 | return response 56 | 57 | 58 | def get(app, user, path, params=None, headers=None): 59 | url = app.base_url + path 60 | 61 | headers = headers or {} 62 | headers["Authorization"] = f"Bearer {user.access_token}" 63 | 64 | request = Request('GET', url, headers, params=params) 65 | response = send_request(request) 66 | 67 | return process_response(response) 68 | 69 | 70 | def get_paged(app, user, path, params=None, headers=None): 71 | if params: 72 | path += f"?{urlencode(params)}" 73 | 74 | while path: 75 | response = get(app, user, path, headers=headers) 76 | yield response 77 | path = _next_path(response) 78 | 79 | 80 | def _next_path(response): 81 | next_link = response.links.get("next") 82 | if next_link: 83 | next_url = urlparse(next_link["url"]) 84 | return "?".join([next_url.path, next_url.query]) 85 | 86 | 87 | def anon_get(url, params=None): 88 | request = Request('GET', url, None, params=params) 89 | response = send_request(request) 90 | 91 | return process_response(response) 92 | 93 | 94 | def anon_get_paged(url, params=None): 95 | if params: 96 | url += f"?{urlencode(params)}" 97 | 98 | while url: 99 | response = anon_get(url) 100 | yield response 101 | url = _next_url(response) 102 | 103 | 104 | def _next_url(response): 105 | next_link = response.links.get("next") 106 | if next_link: 107 | return next_link["url"] 108 | 109 | 110 | def post(app, user, path, headers=None, files=None, data=None, json=None, allow_redirects=True): 111 | url = app.base_url + path 112 | 113 | headers = headers or {} 114 | headers["Authorization"] = f"Bearer {user.access_token}" 115 | 116 | return anon_post(url, headers=headers, files=files, data=data, json=json, allow_redirects=allow_redirects) 117 | 118 | 119 | def anon_put(url, headers=None, files=None, data=None, json=None, allow_redirects=True): 120 | request = Request(method="PUT", url=url, headers=headers, files=files, data=data, json=json) 121 | response = send_request(request, allow_redirects) 122 | 123 | return process_response(response) 124 | 125 | 126 | def put(app, user, path, headers=None, files=None, data=None, json=None, allow_redirects=True): 127 | url = app.base_url + path 128 | 129 | headers = headers or {} 130 | headers["Authorization"] = f"Bearer {user.access_token}" 131 | 132 | return anon_put(url, headers=headers, files=files, data=data, json=json, allow_redirects=allow_redirects) 133 | 134 | 135 | def patch(app, user, path, headers=None, files=None, data=None, json=None): 136 | url = app.base_url + path 137 | 138 | headers = headers or {} 139 | headers["Authorization"] = f"Bearer {user.access_token}" 140 | 141 | request = Request('PATCH', url, headers=headers, files=files, data=data, json=json) 142 | response = send_request(request) 143 | 144 | return process_response(response) 145 | 146 | 147 | def delete(app, user, path, data=None, json=None, headers=None): 148 | url = app.base_url + path 149 | 150 | headers = headers or {} 151 | headers["Authorization"] = f"Bearer {user.access_token}" 152 | 153 | request = Request('DELETE', url, headers=headers, data=data, json=json) 154 | response = send_request(request) 155 | 156 | return process_response(response) 157 | 158 | 159 | def anon_post(url, headers=None, files=None, data=None, json=None, allow_redirects=True): 160 | request = Request(method="POST", url=url, headers=headers, files=files, data=data, json=json) 161 | response = send_request(request, allow_redirects) 162 | 163 | return process_response(response) 164 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pytest 3 | 4 | from toot import User, App, config 5 | 6 | 7 | @pytest.fixture 8 | def sample_config(): 9 | return { 10 | 'apps': { 11 | 'foo.social': { 12 | 'base_url': 'https://foo.social', 13 | 'client_id': 'abc', 14 | 'client_secret': 'def', 15 | 'instance': 'foo.social' 16 | }, 17 | 'bar.social': { 18 | 'base_url': 'https://bar.social', 19 | 'client_id': 'ghi', 20 | 'client_secret': 'jkl', 21 | 'instance': 'bar.social' 22 | }, 23 | }, 24 | 'users': { 25 | 'foo@bar.social': { 26 | 'access_token': 'mno', 27 | 'instance': 'bar.social', 28 | 'username': 'ihabunek' 29 | } 30 | }, 31 | 'active_user': 'foo@bar.social', 32 | } 33 | 34 | 35 | def test_extract_active_user_app(sample_config): 36 | user, app = config.extract_user_app(sample_config, sample_config['active_user']) 37 | 38 | assert isinstance(user, User) 39 | assert user.instance == 'bar.social' 40 | assert user.username == 'ihabunek' 41 | assert user.access_token == 'mno' 42 | 43 | assert isinstance(app, App) 44 | assert app.instance == 'bar.social' 45 | assert app.base_url == 'https://bar.social' 46 | assert app.client_id == 'ghi' 47 | assert app.client_secret == 'jkl' 48 | 49 | 50 | def test_extract_active_when_no_active_user(sample_config): 51 | # When there is no active user 52 | assert config.extract_user_app(sample_config, None) == (None, None) 53 | 54 | # When active user does not exist for whatever reason 55 | assert config.extract_user_app(sample_config, 'does-not-exist') == (None, None) 56 | 57 | # When active app does not exist for whatever reason 58 | sample_config['users']['foo@bar.social']['instance'] = 'does-not-exist' 59 | assert config.extract_user_app(sample_config, 'foo@bar.social') == (None, None) 60 | 61 | 62 | def test_save_app(sample_config): 63 | pytest.skip("TODO: fix mocking") 64 | app = App('xxx.yyy', 2, 3, 4) 65 | app2 = App('moo.foo', 5, 6, 7) 66 | 67 | app_count = len(sample_config['apps']) 68 | assert 'xxx.yyy' not in sample_config['apps'] 69 | assert 'moo.foo' not in sample_config['apps'] 70 | 71 | # Sets 72 | config.save_app.__wrapped__(sample_config, app) 73 | assert len(sample_config['apps']) == app_count + 1 74 | assert 'xxx.yyy' in sample_config['apps'] 75 | assert sample_config['apps']['xxx.yyy']['instance'] == 'xxx.yyy' 76 | assert sample_config['apps']['xxx.yyy']['base_url'] == 2 77 | assert sample_config['apps']['xxx.yyy']['client_id'] == 3 78 | assert sample_config['apps']['xxx.yyy']['client_secret'] == 4 79 | 80 | # Overwrites 81 | config.save_app.__wrapped__(sample_config, app2) 82 | assert len(sample_config['apps']) == app_count + 2 83 | assert 'xxx.yyy' in sample_config['apps'] 84 | assert 'moo.foo' in sample_config['apps'] 85 | assert sample_config['apps']['xxx.yyy']['instance'] == 'xxx.yyy' 86 | assert sample_config['apps']['xxx.yyy']['base_url'] == 2 87 | assert sample_config['apps']['xxx.yyy']['client_id'] == 3 88 | assert sample_config['apps']['xxx.yyy']['client_secret'] == 4 89 | assert sample_config['apps']['moo.foo']['instance'] == 'moo.foo' 90 | assert sample_config['apps']['moo.foo']['base_url'] == 5 91 | assert sample_config['apps']['moo.foo']['client_id'] == 6 92 | assert sample_config['apps']['moo.foo']['client_secret'] == 7 93 | 94 | # Idempotent 95 | config.save_app.__wrapped__(sample_config, app2) 96 | assert len(sample_config['apps']) == app_count + 2 97 | assert 'xxx.yyy' in sample_config['apps'] 98 | assert 'moo.foo' in sample_config['apps'] 99 | assert sample_config['apps']['xxx.yyy']['instance'] == 'xxx.yyy' 100 | assert sample_config['apps']['xxx.yyy']['base_url'] == 2 101 | assert sample_config['apps']['xxx.yyy']['client_id'] == 3 102 | assert sample_config['apps']['xxx.yyy']['client_secret'] == 4 103 | assert sample_config['apps']['moo.foo']['instance'] == 'moo.foo' 104 | assert sample_config['apps']['moo.foo']['base_url'] == 5 105 | assert sample_config['apps']['moo.foo']['client_id'] == 6 106 | assert sample_config['apps']['moo.foo']['client_secret'] == 7 107 | 108 | 109 | def test_delete_app(sample_config): 110 | pytest.skip("TODO: fix mocking") 111 | app = App('foo.social', 2, 3, 4) 112 | 113 | app_count = len(sample_config['apps']) 114 | 115 | assert 'foo.social' in sample_config['apps'] 116 | 117 | config.delete_app.__wrapped__(sample_config, app) 118 | assert 'foo.social' not in sample_config['apps'] 119 | assert len(sample_config['apps']) == app_count - 1 120 | 121 | # Idempotent 122 | config.delete_app.__wrapped__(sample_config, app) 123 | assert 'foo.social' not in sample_config['apps'] 124 | assert len(sample_config['apps']) == app_count - 1 125 | 126 | 127 | def test_get_config_file_path(): 128 | fn = config.get_config_file_path 129 | 130 | os.unsetenv('XDG_CONFIG_HOME') 131 | os.environ.pop('XDG_CONFIG_HOME', None) 132 | 133 | assert fn() == os.path.expanduser('~/.config/toot/config.json') 134 | 135 | os.environ['XDG_CONFIG_HOME'] = '/foo/bar/config' 136 | 137 | assert fn() == '/foo/bar/config/toot/config.json' 138 | 139 | os.environ['XDG_CONFIG_HOME'] = '~/foo/config' 140 | 141 | assert fn() == os.path.expanduser('~/foo/config/toot/config.json') 142 | -------------------------------------------------------------------------------- /tests/integration/test_update_account.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | from tests.integration.conftest import TRUMPET, assert_ok 3 | from toot import api, cli 4 | from toot.entities import Account, from_dict 5 | from toot.utils import get_text 6 | 7 | 8 | def test_update_account_no_options(run): 9 | result = run(cli.accounts.update_account) 10 | assert result.exit_code == 1 11 | assert result.stderr.strip() == "Error: Please specify at least one option to update the account" 12 | 13 | 14 | def test_update_account_display_name(run, app, user): 15 | name = str(uuid4())[:10] 16 | 17 | result = run(cli.accounts.update_account, "--display-name", name) 18 | assert_ok(result) 19 | assert result.stdout.strip() == "✓ Account updated" 20 | 21 | account = api.verify_credentials(app, user).json() 22 | assert account["display_name"] == name 23 | 24 | 25 | def test_update_account_json(run_json, app, user): 26 | name = str(uuid4())[:10] 27 | out = run_json(cli.accounts.update_account, "--display-name", name, "--json") 28 | account = from_dict(Account, out) 29 | assert account.acct == user.username 30 | assert account.display_name == name 31 | 32 | 33 | def test_update_account_note(run, app, user): 34 | note = ("It's 106 miles to Chicago, we got a full tank of gas, half a pack " 35 | "of cigarettes, it's dark... and we're wearing sunglasses.") 36 | 37 | result = run(cli.accounts.update_account, "--note", note) 38 | assert_ok(result) 39 | assert result.stdout.strip() == "✓ Account updated" 40 | 41 | account = api.verify_credentials(app, user).json() 42 | assert get_text(account["note"]) == note 43 | 44 | 45 | def test_update_account_language(run, app, user): 46 | result = run(cli.accounts.update_account, "--language", "hr") 47 | assert_ok(result) 48 | assert result.stdout.strip() == "✓ Account updated" 49 | 50 | account = api.verify_credentials(app, user).json() 51 | assert account["source"]["language"] == "hr" 52 | 53 | 54 | def test_update_account_privacy(run, app, user): 55 | result = run(cli.accounts.update_account, "--privacy", "private") 56 | assert_ok(result) 57 | assert result.stdout.strip() == "✓ Account updated" 58 | 59 | account = api.verify_credentials(app, user).json() 60 | assert account["source"]["privacy"] == "private" 61 | 62 | 63 | def test_update_account_avatar(run, app, user): 64 | account = api.verify_credentials(app, user).json() 65 | old_value = account["avatar"] 66 | 67 | result = run(cli.accounts.update_account, "--avatar", TRUMPET) 68 | assert_ok(result) 69 | assert result.stdout.strip() == "✓ Account updated" 70 | 71 | account = api.verify_credentials(app, user).json() 72 | assert account["avatar"] != old_value 73 | 74 | 75 | def test_update_account_header(run, app, user): 76 | account = api.verify_credentials(app, user).json() 77 | old_value = account["header"] 78 | 79 | result = run(cli.accounts.update_account, "--header", TRUMPET) 80 | assert_ok(result) 81 | assert result.stdout.strip() == "✓ Account updated" 82 | 83 | account = api.verify_credentials(app, user).json() 84 | assert account["header"] != old_value 85 | 86 | 87 | def test_update_account_locked(run, app, user): 88 | result = run(cli.accounts.update_account, "--locked") 89 | assert_ok(result) 90 | assert result.stdout.strip() == "✓ Account updated" 91 | 92 | account = api.verify_credentials(app, user).json() 93 | assert account["locked"] is True 94 | 95 | result = run(cli.accounts.update_account, "--no-locked") 96 | assert_ok(result) 97 | assert result.stdout.strip() == "✓ Account updated" 98 | 99 | account = api.verify_credentials(app, user).json() 100 | assert account["locked"] is False 101 | 102 | 103 | def test_update_account_bot(run, app, user): 104 | result = run(cli.accounts.update_account, "--bot") 105 | 106 | assert_ok(result) 107 | assert result.stdout.strip() == "✓ Account updated" 108 | 109 | account = api.verify_credentials(app, user).json() 110 | assert account["bot"] is True 111 | 112 | result = run(cli.accounts.update_account, "--no-bot") 113 | assert_ok(result) 114 | assert result.stdout.strip() == "✓ Account updated" 115 | 116 | account = api.verify_credentials(app, user).json() 117 | assert account["bot"] is False 118 | 119 | 120 | def test_update_account_discoverable(run, app, user): 121 | result = run(cli.accounts.update_account, "--discoverable") 122 | assert_ok(result) 123 | assert result.stdout.strip() == "✓ Account updated" 124 | 125 | account = api.verify_credentials(app, user).json() 126 | assert account["discoverable"] is True 127 | 128 | result = run(cli.accounts.update_account, "--no-discoverable") 129 | assert_ok(result) 130 | assert result.stdout.strip() == "✓ Account updated" 131 | 132 | account = api.verify_credentials(app, user).json() 133 | assert account["discoverable"] is False 134 | 135 | 136 | def test_update_account_sensitive(run, app, user): 137 | result = run(cli.accounts.update_account, "--sensitive") 138 | assert_ok(result) 139 | assert result.stdout.strip() == "✓ Account updated" 140 | 141 | account = api.verify_credentials(app, user).json() 142 | assert account["source"]["sensitive"] is True 143 | 144 | result = run(cli.accounts.update_account, "--no-sensitive") 145 | assert_ok(result) 146 | assert result.stdout.strip() == "✓ Account updated" 147 | 148 | account = api.verify_credentials(app, user).json() 149 | assert account["source"]["sensitive"] is False 150 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Toot contribution guide 2 | ======================= 3 | 4 | Firstly, thank you for contributing to toot! 5 | 6 | Relevant links which will be referenced below: 7 | 8 | * [toot documentation](https://toot.bezdomni.net/) 9 | * [toot-discuss mailing list](https://lists.sr.ht/~ihabunek/toot-discuss) 10 | used for discussion as well as accepting patches 11 | * [toot project on github](https://github.com/ihabunek/toot) 12 | here you can report issues and submit pull requests 13 | * #toot IRC channel on [libera.chat](https://libera.chat) 14 | 15 | ## Code of conduct 16 | 17 | Please be kind and patient. Toot is maintained by one human with a full time 18 | job. 19 | 20 | ## I have a question 21 | 22 | First, check if your question is addressed in the documentation or the mailing 23 | list. If not, feel free to send an email to the mailing list. You may want to 24 | subscribe to the mailing list to receive replies. 25 | 26 | Alternatively, you can ask your question on the IRC channel and ping me 27 | (ihabunek). You may have to wait for a response, please be patient. 28 | 29 | Please don't open Github issues for questions. 30 | 31 | ## I want to contribute 32 | 33 | ### Reporting a bug 34 | 35 | First check you're using the 36 | [latest version](https://github.com/ihabunek/toot/releases/) of toot and verify 37 | the bug is present in this version. 38 | 39 | Search [Github issues](https://github.com/ihabunek/toot/issues) to check the bug 40 | hasn't already been reported. 41 | 42 | To report a bug open an 43 | [issue on Github](https://github.com/ihabunek/toot/issues) or send an 44 | email to the [mailing list](https://lists.sr.ht/~ihabunek/toot-discuss). 45 | 46 | * Run `toot env` and include its contents in the bug report. 47 | * Explain the behavior you would expect and the actual behavior. 48 | * Please provide as much context as possible and describe the reproduction steps 49 | that someone else can follow to recreate the issue on their own. 50 | 51 | ### Suggesting enhancements 52 | 53 | This includes suggesting new features or changes to existing ones. 54 | 55 | Search Github issues to check the enhancement has not already been requested. If 56 | it hasn't, [open a new issue](https://github.com/ihabunek/toot/issues). 57 | 58 | Your request will be reviewed to see if it's a good fit for toot. Implementing 59 | requested features depends on the available time and energy of the maintainer 60 | and other contributors. Be patient. 61 | 62 | ### Contributing code 63 | 64 | When contributing to toot, please only submit code that you have authored or 65 | code whose license allows it to be included in toot. You agree that the code 66 | you submit will be published under the [toot license](LICENSE). 67 | 68 | #### Setting up a dev environment 69 | 70 | Check out toot (or a fork): 71 | 72 | ``` 73 | git clone git@github.com:ihabunek/toot.git 74 | cd toot 75 | ``` 76 | 77 | Using [uv](https://docs.astral.sh/uv/) simplifies setting up a python virtual 78 | environment and running toot so you can just run: 79 | 80 | ``` 81 | uv run toot 82 | ``` 83 | 84 | If you don't wish to use a third party tool you can do this manually: 85 | 86 | ``` 87 | python3 -m venv .venv 88 | 89 | # On Linux/Mac 90 | source .venv/bin/activate 91 | 92 | # On Windows 93 | .venv\bin\activate.bat 94 | 95 | pip install --upgrade pip 96 | pip install --group dev --editable . 97 | ``` 98 | 99 | While the virtual env is active, you can run `python3 -m toot` to 100 | execute the one you checked out. This allows you to make changes and 101 | test them. 102 | 103 | #### Crafting good commits 104 | 105 | Please put some effort into breaking your contribution up into a series of well 106 | formed commits. If you're unsure what this means, there is a good guide 107 | available at [https://cbea.ms/git-commit/](https://cbea.ms/git-commit/). 108 | 109 | Rules for commits: 110 | 111 | * each commit should ideally contain only one change 112 | * don't bundle multiple unrelated changes into a single commit 113 | * write descriptive and well formatted commit messages 114 | 115 | Rules for commit messages: 116 | 117 | * separate subject from body with a blank line 118 | * limit the subject line to 50 characters 119 | * capitalize the subject line 120 | * do not end the subject line with a period 121 | * use the imperative mood in the subject line 122 | * wrap the body at 72 characters 123 | * use the body to explain what and why vs. how 124 | 125 | For a more detailed explanation with examples see the guide at 126 | [https://cbea.ms/git-commit/](https://cbea.ms/git-commit/) 127 | 128 | If you use vim to write your commit messages, it will already enforce some of 129 | these rules for you. 130 | 131 | #### Run tests before submitting 132 | 133 | You can run code and style tests by running: 134 | 135 | ``` 136 | make test 137 | ``` 138 | 139 | This runs three tools: 140 | 141 | * `pytest` runs the test suite 142 | * `flake8` checks code formatting 143 | * `vermin` checks that minimum python version 144 | 145 | Please ensure all three commands succeed before submitting your patches. 146 | 147 | #### Submitting patches 148 | 149 | To submit your code either open 150 | [a pull request](https://github.com/ihabunek/toot/pulls) on Github, or send 151 | patch(es) to [the mailing list](https://lists.sr.ht/~ihabunek/toot-discuss). 152 | 153 | If sending to the mailing list, patches should be sent using `git send-email`. 154 | If you're unsure how to do this, there is a good guide at 155 | [https://git-send-email.io/](https://git-send-email.io/). 156 | 157 | --- 158 | 159 | Parts of this guide were taken from the following sources: 160 | 161 | * [https://contributing.md/](https://contributing.md/) 162 | * [https://cbea.ms/git-commit/](https://cbea.ms/git-commit/) 163 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | Toot contribution guide 2 | ======================= 3 | 4 | Firstly, thank you for contributing to toot! 5 | 6 | Relevant links which will be referenced below: 7 | 8 | * [toot documentation](https://toot.bezdomni.net/) 9 | * [toot-discuss mailing list](https://lists.sr.ht/~ihabunek/toot-discuss) 10 | used for discussion as well as accepting patches 11 | * [toot project on github](https://github.com/ihabunek/toot) 12 | here you can report issues and submit pull requests 13 | * #toot IRC channel on [libera.chat](https://libera.chat) 14 | 15 | ## Code of conduct 16 | 17 | Please be kind and patient. Toot is maintained by one human with a full time 18 | job. 19 | 20 | ## I have a question 21 | 22 | First, check if your question is addressed in the documentation or the mailing 23 | list. If not, feel free to send an email to the mailing list. You may want to 24 | subscribe to the mailing list to receive replies. 25 | 26 | Alternatively, you can ask your question on the IRC channel and ping me 27 | (ihabunek). You may have to wait for a response, please be patient. 28 | 29 | Please don't open Github issues for questions. 30 | 31 | ## I want to contribute 32 | 33 | ### Reporting a bug 34 | 35 | First check you're using the 36 | [latest version](https://github.com/ihabunek/toot/releases/) of toot and verify 37 | the bug is present in this version. 38 | 39 | Search [Github issues](https://github.com/ihabunek/toot/issues) to check the bug 40 | hasn't already been reported. 41 | 42 | To report a bug open an 43 | [issue on Github](https://github.com/ihabunek/toot/issues) or send an 44 | email to the [mailing list](https://lists.sr.ht/~ihabunek/toot-discuss). 45 | 46 | * Run `toot env` and include its contents in the bug report. 47 | * Explain the behavior you would expect and the actual behavior. 48 | * Please provide as much context as possible and describe the reproduction steps 49 | that someone else can follow to recreate the issue on their own. 50 | 51 | ### Suggesting enhancements 52 | 53 | This includes suggesting new features or changes to existing ones. 54 | 55 | Search Github issues to check the enhancement has not already been requested. If 56 | it hasn't, [open a new issue](https://github.com/ihabunek/toot/issues). 57 | 58 | Your request will be reviewed to see if it's a good fit for toot. Implementing 59 | requested features depends on the available time and energy of the maintainer 60 | and other contributors. Be patient. 61 | 62 | ### Contributing code 63 | 64 | When contributing to toot, please only submit code that you have authored or 65 | code whose license allows it to be included in toot. You agree that the code 66 | you submit will be published under the [toot license](LICENSE). 67 | 68 | #### Setting up a dev environment 69 | 70 | Check out toot (or a fork): 71 | 72 | ``` 73 | git clone git@github.com:ihabunek/toot.git 74 | cd toot 75 | ``` 76 | 77 | Using [uv](https://docs.astral.sh/uv/) simplifies setting up a python virtual 78 | environment and running toot so you can just run: 79 | 80 | ``` 81 | uv run toot 82 | ``` 83 | 84 | If you don't wish to use a third party tool you can do this manually: 85 | 86 | ``` 87 | python3 -m venv .venv 88 | 89 | # On Linux/Mac 90 | source .venv/bin/activate 91 | 92 | # On Windows 93 | .venv\bin\activate.bat 94 | 95 | pip install --upgrade pip 96 | pip install --group dev --editable . 97 | ``` 98 | 99 | While the virtual env is active, you can run `python3 -m toot` to 100 | execute the one you checked out. This allows you to make changes and 101 | test them. 102 | 103 | #### Crafting good commits 104 | 105 | Please put some effort into breaking your contribution up into a series of well 106 | formed commits. If you're unsure what this means, there is a good guide 107 | available at [https://cbea.ms/git-commit/](https://cbea.ms/git-commit/). 108 | 109 | Rules for commits: 110 | 111 | * each commit should ideally contain only one change 112 | * don't bundle multiple unrelated changes into a single commit 113 | * write descriptive and well formatted commit messages 114 | 115 | Rules for commit messages: 116 | 117 | * separate subject from body with a blank line 118 | * limit the subject line to 50 characters 119 | * capitalize the subject line 120 | * do not end the subject line with a period 121 | * use the imperative mood in the subject line 122 | * wrap the body at 72 characters 123 | * use the body to explain what and why vs. how 124 | 125 | For a more detailed explanation with examples see the guide at 126 | [https://cbea.ms/git-commit/](https://cbea.ms/git-commit/) 127 | 128 | If you use vim to write your commit messages, it will already enforce some of 129 | these rules for you. 130 | 131 | #### Run tests before submitting 132 | 133 | You can run code and style tests by running: 134 | 135 | ``` 136 | make test 137 | ``` 138 | 139 | This runs three tools: 140 | 141 | * `pytest` runs the test suite 142 | * `flake8` checks code formatting 143 | * `vermin` checks that minimum python version 144 | 145 | Please ensure all three commands succeed before submitting your patches. 146 | 147 | #### Submitting patches 148 | 149 | To submit your code either open 150 | [a pull request](https://github.com/ihabunek/toot/pulls) on Github, or send 151 | patch(es) to [the mailing list](https://lists.sr.ht/~ihabunek/toot-discuss). 152 | 153 | If sending to the mailing list, patches should be sent using `git send-email`. 154 | If you're unsure how to do this, there is a good guide at 155 | [https://git-send-email.io/](https://git-send-email.io/). 156 | 157 | --- 158 | 159 | Parts of this guide were taken from the following sources: 160 | 161 | * [https://contributing.md/](https://contributing.md/) 162 | * [https://cbea.ms/git-commit/](https://cbea.ms/git-commit/) 163 | -------------------------------------------------------------------------------- /tests/integration/test_lists.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | from toot import cli 3 | 4 | from tests.integration.conftest import assert_ok, register_account 5 | 6 | 7 | def test_lists_empty(run): 8 | result = run(cli.lists.list) 9 | assert_ok(result) 10 | assert result.stdout.strip() == "You have no lists defined." 11 | 12 | 13 | def test_lists_empty_json(run_json): 14 | lists = run_json(cli.lists.list, "--json") 15 | assert lists == [] 16 | 17 | 18 | def test_list_create_delete(run): 19 | result = run(cli.lists.create, "banana") 20 | assert_ok(result) 21 | assert result.stdout.strip() == '✓ List "banana" created.' 22 | 23 | result = run(cli.lists.list) 24 | assert_ok(result) 25 | assert "banana" in result.stdout 26 | 27 | result = run(cli.lists.create, "mango") 28 | assert_ok(result) 29 | assert result.stdout.strip() == '✓ List "mango" created.' 30 | 31 | result = run(cli.lists.list) 32 | assert_ok(result) 33 | assert "banana" in result.stdout 34 | assert "mango" in result.stdout 35 | 36 | result = run(cli.lists.delete, "banana") 37 | assert_ok(result) 38 | assert result.stdout.strip() == '✓ List "banana" deleted.' 39 | 40 | result = run(cli.lists.list) 41 | assert_ok(result) 42 | assert "banana" not in result.stdout 43 | assert "mango" in result.stdout 44 | 45 | result = run(cli.lists.delete, "mango") 46 | assert_ok(result) 47 | assert result.stdout.strip() == '✓ List "mango" deleted.' 48 | 49 | result = run(cli.lists.list) 50 | assert_ok(result) 51 | assert result.stdout.strip() == "You have no lists defined." 52 | 53 | result = run(cli.lists.delete, "mango") 54 | assert result.exit_code == 1 55 | assert result.stderr.strip() == "Error: List not found" 56 | 57 | 58 | def test_list_create_delete_json(run, run_json): 59 | result = run_json(cli.lists.list, "--json") 60 | assert result == [] 61 | 62 | list = run_json(cli.lists.create, "banana", "--json") 63 | assert list["title"] == "banana" 64 | 65 | [list] = run_json(cli.lists.list, "--json") 66 | assert list["title"] == "banana" 67 | 68 | list = run_json(cli.lists.create, "mango", "--json") 69 | assert list["title"] == "mango" 70 | 71 | lists = run_json(cli.lists.list, "--json") 72 | [list1, list2] = sorted(lists, key=lambda l: l["title"]) 73 | assert list1["title"] == "banana" 74 | assert list2["title"] == "mango" 75 | 76 | result = run_json(cli.lists.delete, "banana", "--json") 77 | assert result == {} 78 | 79 | [list] = run_json(cli.lists.list, "--json") 80 | assert list["title"] == "mango" 81 | 82 | result = run_json(cli.lists.delete, "mango", "--json") 83 | assert result == {} 84 | 85 | result = run_json(cli.lists.list, "--json") 86 | assert result == [] 87 | 88 | result = run(cli.lists.delete, "mango", "--json") 89 | assert result.exit_code == 1 90 | assert result.stderr.strip() == "Error: List not found" 91 | 92 | 93 | def test_list_add_remove(run, app): 94 | list_name = str(uuid4()) 95 | acc = register_account(app) 96 | run(cli.lists.create, list_name) 97 | 98 | result = run(cli.lists.add, list_name, acc.username) 99 | assert result.exit_code == 1 100 | assert result.stderr.strip() == f"Error: You must follow @{acc.username} before adding this account to a list." 101 | 102 | run(cli.accounts.follow, acc.username) 103 | 104 | result = run(cli.lists.add, list_name, acc.username) 105 | assert_ok(result) 106 | assert result.stdout.strip() == f'✓ Added account "{acc.username}"' 107 | 108 | result = run(cli.lists.accounts, list_name) 109 | assert_ok(result) 110 | assert acc.username in result.stdout 111 | 112 | # Account doesn't exist 113 | result = run(cli.lists.add, list_name, "does_not_exist") 114 | assert result.exit_code == 1 115 | assert result.stderr.strip() == "Error: Account not found" 116 | 117 | # List doesn't exist 118 | result = run(cli.lists.add, "does_not_exist", acc.username) 119 | assert result.exit_code == 1 120 | assert result.stderr.strip() == "Error: List not found" 121 | 122 | result = run(cli.lists.remove, list_name, acc.username) 123 | assert_ok(result) 124 | assert result.stdout.strip() == f'✓ Removed account "{acc.username}"' 125 | 126 | result = run(cli.lists.accounts, list_name) 127 | assert_ok(result) 128 | assert result.stdout.strip() == "This list has no accounts." 129 | 130 | 131 | def test_list_add_remove_json(run, run_json, app): 132 | list_name = str(uuid4()) 133 | acc = register_account(app) 134 | run(cli.lists.create, list_name) 135 | 136 | result = run(cli.lists.add, list_name, acc.username, "--json") 137 | assert result.exit_code == 1 138 | assert result.stderr.strip() == f"Error: You must follow @{acc.username} before adding this account to a list." 139 | 140 | run(cli.accounts.follow, acc.username) 141 | 142 | result = run_json(cli.lists.add, list_name, acc.username, "--json") 143 | assert result == {} 144 | 145 | [account] = run_json(cli.lists.accounts, list_name, "--json") 146 | assert account["username"] == acc.username 147 | 148 | # Account doesn't exist 149 | result = run(cli.lists.add, list_name, "does_not_exist", "--json") 150 | assert result.exit_code == 1 151 | assert result.stderr.strip() == "Error: Account not found" 152 | 153 | # List doesn't exist 154 | result = run(cli.lists.add, "does_not_exist", acc.username, "--json") 155 | assert result.exit_code == 1 156 | assert result.stderr.strip() == "Error: List not found" 157 | 158 | result = run_json(cli.lists.remove, list_name, acc.username, "--json") 159 | assert result == {} 160 | 161 | result = run_json(cli.lists.accounts, list_name, "--json") 162 | assert result == [] 163 | -------------------------------------------------------------------------------- /tests/integration/test_tags.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import List 3 | 4 | from tests.integration.conftest import assert_ok 5 | from toot import api, cli 6 | from toot.entities import FeaturedTag, Tag, from_dict, from_dict_list 7 | 8 | 9 | def test_tags(run): 10 | result = run(cli.tags.tags, "followed") 11 | assert_ok(result) 12 | assert result.stdout.strip() == "You're not following any hashtags" 13 | 14 | result = run(cli.tags.tags, "follow", "foo") 15 | assert_ok(result) 16 | assert result.stdout.strip() == "✓ You are now following #foo" 17 | 18 | result = run(cli.tags.tags, "followed") 19 | assert_ok(result) 20 | assert _find_tags(result.stdout) == ["#foo"] 21 | 22 | result = run(cli.tags.tags, "follow", "bar") 23 | assert_ok(result) 24 | assert result.stdout.strip() == "✓ You are now following #bar" 25 | 26 | result = run(cli.tags.tags, "followed") 27 | assert_ok(result) 28 | assert _find_tags(result.stdout) == ["#bar", "#foo"] 29 | 30 | result = run(cli.tags.tags, "unfollow", "foo") 31 | assert_ok(result) 32 | assert result.stdout.strip() == "✓ You are no longer following #foo" 33 | 34 | result = run(cli.tags.tags, "followed") 35 | assert_ok(result) 36 | assert _find_tags(result.stdout) == ["#bar"] 37 | 38 | result = run(cli.tags.tags, "unfollow", "bar") 39 | assert_ok(result) 40 | assert result.stdout.strip() == "✓ You are no longer following #bar" 41 | 42 | result = run(cli.tags.tags, "followed") 43 | assert_ok(result) 44 | assert result.stdout.strip() == "You're not following any hashtags" 45 | 46 | 47 | def test_tags_json(run_json): 48 | result = run_json(cli.tags.tags, "followed", "--json") 49 | assert result == [] 50 | 51 | result = run_json(cli.tags.tags, "follow", "foo", "--json") 52 | tag = from_dict(Tag, result) 53 | assert tag.name == "foo" 54 | assert tag.following is True 55 | 56 | result = run_json(cli.tags.tags, "followed", "--json") 57 | [tag] = from_dict_list(Tag, result) 58 | assert tag.name == "foo" 59 | assert tag.following is True 60 | 61 | result = run_json(cli.tags.tags, "follow", "bar", "--json") 62 | tag = from_dict(Tag, result) 63 | assert tag.name == "bar" 64 | assert tag.following is True 65 | 66 | result = run_json(cli.tags.tags, "followed", "--json") 67 | tags = from_dict_list(Tag, result) 68 | [bar, foo] = sorted(tags, key=lambda t: t.name) 69 | assert foo.name == "foo" 70 | assert foo.following is True 71 | assert bar.name == "bar" 72 | assert bar.following is True 73 | 74 | result = run_json(cli.tags.tags, "unfollow", "foo", "--json") 75 | tag = from_dict(Tag, result) 76 | assert tag.name == "foo" 77 | assert tag.following is False 78 | 79 | result = run_json(cli.tags.tags, "unfollow", "bar", "--json") 80 | tag = from_dict(Tag, result) 81 | assert tag.name == "bar" 82 | assert tag.following is False 83 | 84 | result = run_json(cli.tags.tags, "followed", "--json") 85 | assert result == [] 86 | 87 | 88 | def test_tags_featured(run, app, user): 89 | result = run(cli.tags.tags, "featured") 90 | assert_ok(result) 91 | assert result.stdout.strip() == "You don't have any featured hashtags" 92 | 93 | result = run(cli.tags.tags, "feature", "foo") 94 | assert_ok(result) 95 | assert result.stdout.strip() == "✓ Tag #foo is now featured" 96 | 97 | result = run(cli.tags.tags, "featured") 98 | assert_ok(result) 99 | assert _find_tags(result.stdout) == ["#foo"] 100 | 101 | result = run(cli.tags.tags, "feature", "bar") 102 | assert_ok(result) 103 | assert result.stdout.strip() == "✓ Tag #bar is now featured" 104 | 105 | result = run(cli.tags.tags, "featured") 106 | assert_ok(result) 107 | assert _find_tags(result.stdout) == ["#bar", "#foo"] 108 | 109 | # Unfeature by Name 110 | result = run(cli.tags.tags, "unfeature", "foo") 111 | assert_ok(result) 112 | assert result.stdout.strip() == "✓ Tag #foo is no longer featured" 113 | 114 | result = run(cli.tags.tags, "featured") 115 | assert_ok(result) 116 | assert _find_tags(result.stdout) == ["#bar"] 117 | 118 | # Unfeature by ID 119 | tag = api.find_featured_tag(app, user, "bar") 120 | assert tag is not None 121 | 122 | result = run(cli.tags.tags, "unfeature", tag["id"]) 123 | assert_ok(result) 124 | assert result.stdout.strip() == "✓ Tag #bar is no longer featured" 125 | 126 | result = run(cli.tags.tags, "featured") 127 | assert_ok(result) 128 | assert result.stdout.strip() == "You don't have any featured hashtags" 129 | 130 | 131 | def test_tags_featured_json(run_json): 132 | result = run_json(cli.tags.tags, "featured", "--json") 133 | assert result == [] 134 | 135 | result = run_json(cli.tags.tags, "feature", "foo", "--json") 136 | tag = from_dict(FeaturedTag, result) 137 | assert tag.name == "foo" 138 | 139 | result = run_json(cli.tags.tags, "featured", "--json") 140 | [tag] = from_dict_list(FeaturedTag, result) 141 | assert tag.name == "foo" 142 | 143 | result = run_json(cli.tags.tags, "feature", "bar", "--json") 144 | tag = from_dict(FeaturedTag, result) 145 | assert tag.name == "bar" 146 | 147 | result = run_json(cli.tags.tags, "featured", "--json") 148 | tags = from_dict_list(FeaturedTag, result) 149 | [bar, foo] = sorted(tags, key=lambda t: t.name) 150 | assert foo.name == "foo" 151 | assert bar.name == "bar" 152 | 153 | result = run_json(cli.tags.tags, "unfeature", "foo", "--json") 154 | assert result == {} 155 | 156 | result = run_json(cli.tags.tags, "unfeature", "bar", "--json") 157 | assert result == {} 158 | 159 | result = run_json(cli.tags.tags, "featured", "--json") 160 | assert result == [] 161 | 162 | 163 | def _find_tags(txt: str) -> List[str]: 164 | return sorted(re.findall(r"#\w+", txt)) 165 | -------------------------------------------------------------------------------- /toot/utils/__init__.py: -------------------------------------------------------------------------------- 1 | import platform 2 | import click 3 | import os 4 | import re 5 | import subprocess 6 | import tempfile 7 | import unicodedata 8 | import warnings 9 | 10 | from bs4 import BeautifulSoup 11 | from importlib.metadata import version 12 | from itertools import islice 13 | from typing import Any, Dict, Generator, Iterable, List, Optional, TypeVar 14 | from urllib.parse import urlparse, urlencode, quote, unquote 15 | 16 | 17 | def str_bool(b: bool) -> str: 18 | """Convert boolean to string, in the way expected by the API.""" 19 | return "true" if b else "false" 20 | 21 | 22 | def str_bool_nullable(b: Optional[bool]) -> Optional[str]: 23 | """Similar to str_bool, but leave None as None""" 24 | return None if b is None else str_bool(b) 25 | 26 | 27 | def parse_html(html: str) -> BeautifulSoup: 28 | # Ignore warnings made by BeautifulSoup, if passed something that looks like 29 | # a file (e.g. a dot which matches current dict), it will warn that the file 30 | # should be opened instead of passing a filename. 31 | with warnings.catch_warnings(): 32 | warnings.simplefilter("ignore") 33 | return BeautifulSoup(html.replace("'", "'"), "html.parser") 34 | 35 | 36 | def get_text(html: str) -> str: 37 | """Converts html to text, strips all tags.""" 38 | text = parse_html(html).get_text() 39 | return unicodedata.normalize("NFKC", text) 40 | 41 | 42 | def html_to_paragraphs(html: str) -> List[List[str]]: 43 | """Attempt to convert html to plain text while keeping line breaks. 44 | Returns a list of paragraphs, each being a list of lines. 45 | """ 46 | paragraphs = re.split("]*>", html) 47 | 48 | # Convert
s to line breaks and remove empty paragraphs 49 | paragraphs = [re.split("
", p) for p in paragraphs if p] 50 | 51 | # Convert each line in each paragraph to plain text: 52 | return [[get_text(line) for line in p] for p in paragraphs] 53 | 54 | 55 | def format_content(content: str) -> Generator[str, None, None]: 56 | """Given a Status contents in HTML, converts it into lines of plain text. 57 | 58 | Returns a generator yielding lines of content. 59 | """ 60 | 61 | paragraphs = html_to_paragraphs(content) 62 | 63 | first = True 64 | 65 | for paragraph in paragraphs: 66 | if not first: 67 | yield "" 68 | 69 | for line in paragraph: 70 | yield line 71 | 72 | first = False 73 | 74 | 75 | EOF_KEY = "Ctrl-Z" if os.name == 'nt' else "Ctrl-D" 76 | 77 | 78 | def multiline_input() -> str: 79 | """Lets user input multiple lines of text, terminated by EOF.""" 80 | lines: List[str] = [] 81 | while True: 82 | try: 83 | lines.append(input()) 84 | except EOFError: 85 | break 86 | 87 | return "\n".join(lines).strip() 88 | 89 | 90 | EDITOR_DIVIDER = "------------------------ >8 ------------------------" 91 | 92 | EDITOR_INPUT_INSTRUCTIONS = f""" 93 | {EDITOR_DIVIDER} 94 | Do not modify or remove the line above. 95 | Enter your toot above it. 96 | Everything below it will be ignored. 97 | """ 98 | 99 | 100 | def editor_input(editor: str, initial_text: str) -> str: 101 | """Lets user input text using an editor.""" 102 | tmp_path = _tmp_status_path() 103 | initial_text = (initial_text or "") + EDITOR_INPUT_INSTRUCTIONS 104 | 105 | if not _use_existing_tmp_file(tmp_path): 106 | with open(tmp_path, "w") as f: 107 | f.write(initial_text) 108 | f.flush() 109 | 110 | subprocess.run([editor, tmp_path]) 111 | 112 | with open(tmp_path) as f: 113 | return f.read().split(EDITOR_DIVIDER)[0].strip() 114 | 115 | 116 | def delete_tmp_status_file() -> None: 117 | try: 118 | os.unlink(_tmp_status_path()) 119 | except FileNotFoundError: 120 | pass 121 | 122 | 123 | def _tmp_status_path() -> str: 124 | tmp_dir = tempfile.gettempdir() 125 | return f"{tmp_dir}/.status.toot" 126 | 127 | 128 | def _use_existing_tmp_file(tmp_path: str) -> bool: 129 | if os.path.exists(tmp_path): 130 | click.echo(f"Found draft status at: {tmp_path}") 131 | 132 | choice = click.Choice(["O", "D"], case_sensitive=False) 133 | char = click.prompt("Open or Delete?", type=choice, default="O") 134 | return char == "O" 135 | 136 | return False 137 | 138 | 139 | def drop_empty_values(data: Dict[Any, Any]) -> Dict[Any, Any]: 140 | """Remove keys whose values are null""" 141 | return {k: v for k, v in data.items() if v is not None} 142 | 143 | 144 | def urlencode_url(url: str) -> str: 145 | parsed_url = urlparse(url) 146 | 147 | # unencode before encoding, to prevent double-urlencoding 148 | encoded_path = quote(unquote(parsed_url.path), safe="-._~()'!*:@,;+&=/") 149 | encoded_query = urlencode({k: quote(unquote(v), safe="-._~()'!*:@,;?/") for k, v in parsed_url.params}) 150 | encoded_url = parsed_url._replace(path=encoded_path, params=encoded_query).geturl() 151 | 152 | return encoded_url 153 | 154 | 155 | def get_distro_name() -> Optional[str]: 156 | """Attempt to get linux distro name from platform (requires python 3.10+)""" 157 | try: 158 | return platform.freedesktop_os_release()["PRETTY_NAME"] # type: ignore # novermin 159 | except Exception: 160 | pass 161 | 162 | 163 | def get_version(name): 164 | try: 165 | return version(name) 166 | except Exception: 167 | return None 168 | 169 | 170 | T = TypeVar("T") 171 | 172 | 173 | def batched(iterable: Iterable[T], n: int) -> Generator[List[T], None, None]: 174 | """Batch data from the iterable into lists of length n. The last batch may 175 | be shorter than n.""" 176 | if n < 1: 177 | raise ValueError("n must be positive") 178 | iterator = iter(iterable) 179 | while True: 180 | batch = list(islice(iterator, n)) 181 | if batch: 182 | yield batch 183 | else: 184 | break 185 | -------------------------------------------------------------------------------- /tests/integration/test_read.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | 4 | from tests.integration.conftest import TOOT_TEST_BASE_URL, assert_ok 5 | from toot import api, cli 6 | from toot.entities import Account, Status, from_dict, from_dict_list 7 | from uuid import uuid4 8 | 9 | 10 | def test_instance_default(app, run): 11 | result = run(cli.read.instance) 12 | assert_ok(result) 13 | 14 | assert "Mastodon" in result.stdout 15 | assert app.instance in result.stdout 16 | assert "running Mastodon" in result.stdout 17 | 18 | 19 | def test_instance_with_url(app, run): 20 | result = run(cli.read.instance, TOOT_TEST_BASE_URL) 21 | assert_ok(result) 22 | 23 | assert "Mastodon" in result.stdout 24 | assert app.instance in result.stdout 25 | assert "running Mastodon" in result.stdout 26 | 27 | 28 | def test_instance_json(app, run): 29 | result = run(cli.read.instance, "--json") 30 | assert_ok(result) 31 | 32 | data = json.loads(result.stdout) 33 | assert data["title"] is not None 34 | assert data["description"] is not None 35 | assert data["version"] is not None 36 | 37 | 38 | def test_instance_anon(app, run_anon, base_url): 39 | result = run_anon(cli.read.instance, base_url) 40 | assert_ok(result) 41 | 42 | assert "Mastodon" in result.stdout 43 | assert app.instance in result.stdout 44 | assert "running Mastodon" in result.stdout 45 | 46 | # Need to specify the instance name when running anon 47 | result = run_anon(cli.read.instance) 48 | assert result.exit_code == 1 49 | assert result.stderr.strip() == "Error: INSTANCE argument not given and not logged in" 50 | 51 | 52 | def test_whoami(user, run): 53 | result = run(cli.read.whoami) 54 | assert_ok(result) 55 | assert f"@{user.username}" in result.stdout 56 | 57 | 58 | def test_whoami_json(user, run): 59 | result = run(cli.read.whoami, "--json") 60 | assert_ok(result) 61 | 62 | data = json.loads(result.stdout) 63 | account = from_dict(Account, data) 64 | assert account.username == user.username 65 | assert account.acct == user.username 66 | 67 | 68 | def test_whois(app, friend, run): 69 | variants = [ 70 | friend.username, 71 | f"@{friend.username}", 72 | f"{friend.username}@{app.instance}", 73 | f"@{friend.username}@{app.instance}", 74 | ] 75 | 76 | for username in variants: 77 | result = run(cli.read.whois, username) 78 | assert_ok(result) 79 | assert f"@{friend.username}" in result.stdout 80 | 81 | 82 | def test_whois_json(app, friend, run): 83 | result = run(cli.read.whois, friend.username, "--json") 84 | assert_ok(result) 85 | 86 | data = json.loads(result.stdout) 87 | account = from_dict(Account, data) 88 | assert account.username == friend.username 89 | assert account.acct == friend.username 90 | 91 | 92 | def test_search_account(friend, run): 93 | result = run(cli.read.search, friend.username) 94 | assert_ok(result) 95 | assert result.stdout.strip() == f"Accounts:\n* @{friend.username}" 96 | 97 | 98 | def test_search_account_json(friend, run): 99 | result = run(cli.read.search, friend.username, "--json") 100 | assert_ok(result) 101 | 102 | data = json.loads(result.stdout) 103 | [account] = from_dict_list(Account, data["accounts"]) 104 | assert account.acct == friend.username 105 | 106 | 107 | def test_search_hashtag(app, user, run): 108 | api.post_status(app, user, "#hashtag_x") 109 | api.post_status(app, user, "#hashtag_y") 110 | api.post_status(app, user, "#hashtag_z") 111 | 112 | result = run(cli.read.search, "#hashtag") 113 | assert_ok(result) 114 | assert result.stdout.strip() == "Hashtags:\n#hashtag_x, #hashtag_y, #hashtag_z" 115 | 116 | 117 | def test_search_hashtag_json(app, user, run): 118 | api.post_status(app, user, "#hashtag_x") 119 | api.post_status(app, user, "#hashtag_y") 120 | api.post_status(app, user, "#hashtag_z") 121 | 122 | result = run(cli.read.search, "#hashtag", "--json") 123 | assert_ok(result) 124 | 125 | data = json.loads(result.stdout) 126 | [h1, h2, h3] = sorted(data["hashtags"], key=lambda h: h["name"]) 127 | 128 | assert h1["name"] == "hashtag_x" 129 | assert h2["name"] == "hashtag_y" 130 | assert h3["name"] == "hashtag_z" 131 | 132 | 133 | def test_status(app, user, run): 134 | uuid = str(uuid4()) 135 | status_id = api.post_status(app, user, uuid).json()["id"] 136 | 137 | result = run(cli.read.status, status_id) 138 | assert_ok(result) 139 | 140 | out = result.stdout.strip() 141 | assert uuid in out 142 | assert user.username in out 143 | assert status_id in out 144 | 145 | 146 | def test_status_json(app, user, run): 147 | uuid = str(uuid4()) 148 | status_id = api.post_status(app, user, uuid).json()["id"] 149 | 150 | result = run(cli.read.status, status_id, "--json") 151 | assert_ok(result) 152 | 153 | status = from_dict(Status, json.loads(result.stdout)) 154 | assert status.id == status_id 155 | assert status.account.acct == user.username 156 | assert uuid in status.content 157 | 158 | 159 | def test_thread(app, user, run): 160 | uuid1 = str(uuid4()) 161 | uuid2 = str(uuid4()) 162 | uuid3 = str(uuid4()) 163 | 164 | s1 = api.post_status(app, user, uuid1).json() 165 | s2 = api.post_status(app, user, uuid2, in_reply_to_id=s1["id"]).json() 166 | s3 = api.post_status(app, user, uuid3, in_reply_to_id=s2["id"]).json() 167 | 168 | for status in [s1, s2, s3]: 169 | result = run(cli.read.thread, status["id"]) 170 | assert_ok(result) 171 | 172 | bits = re.split(r"─+", result.stdout.strip()) 173 | bits = [b for b in bits if b] 174 | 175 | assert len(bits) == 3 176 | 177 | assert s1["id"] in bits[0] 178 | assert s2["id"] in bits[1] 179 | assert s3["id"] in bits[2] 180 | 181 | assert uuid1 in bits[0] 182 | assert uuid2 in bits[1] 183 | assert uuid3 in bits[2] 184 | 185 | 186 | def test_thread_json(app, user, run): 187 | uuid1 = str(uuid4()) 188 | uuid2 = str(uuid4()) 189 | uuid3 = str(uuid4()) 190 | 191 | s1 = api.post_status(app, user, uuid1).json() 192 | s2 = api.post_status(app, user, uuid2, in_reply_to_id=s1["id"]).json() 193 | s3 = api.post_status(app, user, uuid3, in_reply_to_id=s2["id"]).json() 194 | 195 | result = run(cli.read.thread, s2["id"], "--json") 196 | assert_ok(result) 197 | 198 | result = json.loads(result.stdout) 199 | [ancestor] = [from_dict(Status, s) for s in result["ancestors"]] 200 | [descendent] = [from_dict(Status, s) for s in result["descendants"]] 201 | 202 | assert ancestor.id == s1["id"] 203 | assert descendent.id == s3["id"] 204 | -------------------------------------------------------------------------------- /toot/cli/__init__.py: -------------------------------------------------------------------------------- 1 | import click 2 | import logging 3 | import os 4 | import sys 5 | import typing as t 6 | 7 | from click.shell_completion import CompletionItem 8 | from click.types import StringParamType 9 | from functools import wraps 10 | 11 | from toot import App, User, config, __version__ 12 | from toot.output import print_warning 13 | from toot.settings import get_settings 14 | 15 | if t.TYPE_CHECKING: 16 | import typing_extensions as te 17 | P = te.ParamSpec("P") 18 | 19 | R = t.TypeVar("R") 20 | T = t.TypeVar("T") 21 | 22 | 23 | PRIVACY_CHOICES = ["public", "unlisted", "private"] 24 | VISIBILITY_CHOICES = ["public", "unlisted", "private", "direct"] 25 | IMAGE_FORMAT_CHOICES = ["block", "iterm", "kitty"] 26 | NOTIFICATION_TYPE_CHOICES = [ 27 | "mention", 28 | "status", 29 | "reblog", 30 | "follow", 31 | "follow_request", 32 | "favourite", 33 | "poll", 34 | "update", 35 | "admin.sign_up", 36 | "admin.report", 37 | ] 38 | TUI_COLORS = { 39 | "1": 1, 40 | "16": 16, 41 | "88": 88, 42 | "256": 256, 43 | "16777216": 16777216, 44 | "24bit": 16777216, 45 | } 46 | TUI_COLORS_CHOICES = list(TUI_COLORS.keys()) 47 | TUI_COLORS_VALUES = list(TUI_COLORS.values()) 48 | 49 | DURATION_EXAMPLES = """e.g. "1 day", "2 hours 30 minutes", "5 minutes 30 50 | seconds" or any combination of above. Shorthand: "1d", "2h30m", "5m30s\"""" 51 | 52 | 53 | def get_default_visibility() -> str: 54 | return os.getenv("TOOT_POST_VISIBILITY", "public") 55 | 56 | 57 | def get_default_map(): 58 | settings = get_settings() 59 | common = settings.get("common", {}) 60 | commands = settings.get("commands", {}) 61 | 62 | # TODO: remove in version 1.0 63 | tui_old = settings.get("tui", {}).copy() 64 | if "palette" in tui_old: 65 | del tui_old["palette"] 66 | if tui_old: 67 | # TODO: don't show the warning for [toot.palette] 68 | print_warning("Settings section [tui] has been deprecated in favour of [commands.tui].") 69 | tui_new = commands.get("tui", {}) 70 | commands["tui"] = {**tui_old, **tui_new} 71 | 72 | return {**common, **commands} 73 | 74 | 75 | # Tweak the Click context 76 | # https://click.palletsprojects.com/en/8.1.x/api/#context 77 | CONTEXT = dict( 78 | # Enable using environment variables to set options 79 | auto_envvar_prefix="TOOT", 80 | # Add shorthand -h for invoking help 81 | help_option_names=["-h", "--help"], 82 | # Always show default values for options 83 | show_default=True, 84 | # Load command defaults from settings 85 | default_map=get_default_map(), 86 | ) 87 | 88 | 89 | class Context(t.NamedTuple): 90 | app: t.Optional[App] 91 | user: t.Optional[User] = None 92 | color: bool = False 93 | debug: bool = False 94 | 95 | 96 | class TootObj(t.NamedTuple): 97 | """Data to add to Click context""" 98 | color: bool = True 99 | debug: bool = False 100 | as_user: t.Optional[str] = None 101 | # Pass a context for testing purposes 102 | test_ctx: t.Optional[Context] = None 103 | 104 | 105 | class AccountParamType(StringParamType): 106 | """Custom type to add shell completion for account names""" 107 | name = "account" 108 | 109 | def shell_complete(self, ctx, param, incomplete: str): 110 | users = config.load_config()["users"].keys() 111 | return [ 112 | CompletionItem(u) 113 | for u in users 114 | if u.lower().startswith(incomplete.lower()) 115 | ] 116 | 117 | 118 | class InstanceParamType(StringParamType): 119 | """Custom type to add shell completion for instance domains""" 120 | name = "instance" 121 | 122 | def shell_complete(self, ctx, param, incomplete: str): 123 | apps = config.load_config()["apps"] 124 | 125 | return [ 126 | CompletionItem(i) 127 | for i in apps.keys() 128 | if i.lower().startswith(incomplete.lower()) 129 | ] 130 | 131 | 132 | def pass_context(f: "t.Callable[te.Concatenate[Context, P], R]") -> "t.Callable[P, R]": 133 | """Pass the toot Context as first argument.""" 134 | @wraps(f) 135 | def wrapped(*args: "P.args", **kwargs: "P.kwargs") -> R: 136 | return f(get_context(), *args, **kwargs) 137 | 138 | return wrapped 139 | 140 | 141 | def get_context() -> Context: 142 | click_context = click.get_current_context() 143 | obj: TootObj = click_context.obj 144 | 145 | # This is used to pass a context for testing, not used in normal usage 146 | if obj.test_ctx: 147 | return obj.test_ctx 148 | 149 | if obj.as_user: 150 | user, app = config.get_user_app(obj.as_user) 151 | if not user or not app: 152 | raise click.ClickException(f"Account '{obj.as_user}' not found. Run `toot auth` to see available accounts.") 153 | else: 154 | user, app = config.get_active_user_app() 155 | if not user or not app: 156 | raise click.ClickException("This command requires you to be logged in.") 157 | 158 | return Context(app, user, obj.color, obj.debug) 159 | 160 | 161 | json_option = click.option( 162 | "--json", 163 | is_flag=True, 164 | default=False, 165 | help="Print data as JSON rather than human readable text" 166 | ) 167 | 168 | 169 | @click.group(context_settings=CONTEXT) 170 | @click.option("-w", "--max-width", type=int, default=80, help="Maximum width for content rendered by toot") 171 | @click.option("--debug/--no-debug", default=False, help="Log debug info to stderr") 172 | @click.option("--verbose", is_flag=True, help="Log verbose info") 173 | @click.option("--color/--no-color", default=sys.stdout.isatty(), help="Use ANSI color in output") 174 | @click.option("--as", "as_user", type=AccountParamType(), help="The account to use, overrides the active account.") 175 | @click.version_option(__version__, message="%(prog)s v%(version)s") 176 | @click.pass_context 177 | def cli(ctx: click.Context, max_width: int, color: bool, debug: bool, verbose: bool, as_user: str): 178 | """Toot is a Mastodon CLI""" 179 | ctx.obj = TootObj(color, debug, as_user) 180 | ctx.color = color 181 | ctx.max_content_width = max_width 182 | 183 | if debug: 184 | logging.basicConfig(level=logging.DEBUG) 185 | 186 | 187 | from toot.cli import accounts # noqa 188 | from toot.cli import auth # noqa 189 | from toot.cli import diag # noqa 190 | from toot.cli import follow_requests # noqa 191 | from toot.cli import lists # noqa 192 | from toot.cli import polls # noqa 193 | from toot.cli import post # noqa 194 | from toot.cli import read # noqa 195 | from toot.cli import statuses # noqa 196 | from toot.cli import tags # noqa 197 | from toot.cli import timelines # noqa 198 | from toot.cli import timelines_v2 # noqa 199 | from toot.cli import tui # noqa 200 | -------------------------------------------------------------------------------- /tests/integration/test_status.py: -------------------------------------------------------------------------------- 1 | import json 2 | import pytest 3 | 4 | from tests.integration.conftest import assert_ok 5 | from tests.utils import run_with_retries 6 | from toot import api, cli 7 | from toot.exceptions import NotFoundError 8 | 9 | 10 | def test_delete(app, user, run): 11 | status = api.post_status(app, user, "foo").json() 12 | 13 | result = run(cli.statuses.delete, status["id"]) 14 | assert_ok(result) 15 | assert result.stdout.strip() == "✓ Status deleted" 16 | 17 | with pytest.raises(NotFoundError): 18 | api.fetch_status(app, user, status["id"]) 19 | 20 | 21 | def test_delete_json(app, user, run): 22 | status = api.post_status(app, user, "foo").json() 23 | 24 | result = run(cli.statuses.delete, status["id"], "--json") 25 | assert_ok(result) 26 | 27 | out = result.stdout 28 | result = json.loads(out) 29 | assert result["id"] == status["id"] 30 | 31 | with pytest.raises(NotFoundError): 32 | api.fetch_status(app, user, status["id"]) 33 | 34 | 35 | def test_favourite(app, user, run): 36 | status = api.post_status(app, user, "foo").json() 37 | assert not status["favourited"] 38 | 39 | result = run(cli.statuses.favourite, status["id"]) 40 | assert_ok(result) 41 | assert result.stdout.strip() == "✓ Status favourited" 42 | 43 | status = api.fetch_status(app, user, status["id"]).json() 44 | assert status["favourited"] 45 | 46 | result = run(cli.statuses.unfavourite, status["id"]) 47 | assert_ok(result) 48 | assert result.stdout.strip() == "✓ Status unfavourited" 49 | 50 | def test_favourited(): 51 | nonlocal status 52 | status = api.fetch_status(app, user, status["id"]).json() 53 | assert not status["favourited"] 54 | run_with_retries(test_favourited) 55 | 56 | 57 | def test_favourite_json(app, user, run): 58 | status = api.post_status(app, user, "foo").json() 59 | assert not status["favourited"] 60 | 61 | result = run(cli.statuses.favourite, status["id"], "--json") 62 | assert_ok(result) 63 | 64 | result = json.loads(result.stdout) 65 | assert result["id"] == status["id"] 66 | assert result["favourited"] is True 67 | 68 | result = run(cli.statuses.unfavourite, status["id"], "--json") 69 | assert_ok(result) 70 | 71 | result = json.loads(result.stdout) 72 | assert result["id"] == status["id"] 73 | assert result["favourited"] is False 74 | 75 | 76 | def test_reblog(app, user, run): 77 | status = api.post_status(app, user, "foo").json() 78 | assert not status["reblogged"] 79 | 80 | result = run(cli.statuses.reblogged_by, status["id"]) 81 | assert_ok(result) 82 | assert result.stdout.strip() == "This status is not reblogged by anyone" 83 | 84 | result = run(cli.statuses.reblog, status["id"]) 85 | assert_ok(result) 86 | assert result.stdout.strip() == "✓ Status reblogged" 87 | 88 | status = api.fetch_status(app, user, status["id"]).json() 89 | assert status["reblogged"] 90 | 91 | result = run(cli.statuses.reblogged_by, status["id"]) 92 | assert_ok(result) 93 | assert user.username in result.stdout 94 | 95 | result = run(cli.statuses.unreblog, status["id"]) 96 | assert_ok(result) 97 | assert result.stdout.strip() == "✓ Status unreblogged" 98 | 99 | status = api.fetch_status(app, user, status["id"]).json() 100 | assert not status["reblogged"] 101 | 102 | 103 | def test_reblog_json(app, user, run): 104 | status = api.post_status(app, user, "foo").json() 105 | assert not status["reblogged"] 106 | 107 | result = run(cli.statuses.reblog, status["id"], "--json") 108 | assert_ok(result) 109 | 110 | result = json.loads(result.stdout) 111 | assert result["reblogged"] is True 112 | assert result["reblog"]["id"] == status["id"] 113 | 114 | result = run(cli.statuses.reblogged_by, status["id"], "--json") 115 | assert_ok(result) 116 | 117 | [reblog] = json.loads(result.stdout) 118 | assert reblog["acct"] == user.username 119 | 120 | result = run(cli.statuses.unreblog, status["id"], "--json") 121 | assert_ok(result) 122 | 123 | result = json.loads(result.stdout) 124 | assert result["reblogged"] is False 125 | assert result["reblog"] is None 126 | 127 | 128 | def test_pin(app, user, run): 129 | status = api.post_status(app, user, "foo").json() 130 | assert not status["pinned"] 131 | 132 | result = run(cli.statuses.pin, status["id"]) 133 | assert_ok(result) 134 | assert result.stdout.strip() == "✓ Status pinned" 135 | 136 | status = api.fetch_status(app, user, status["id"]).json() 137 | assert status["pinned"] 138 | 139 | result = run(cli.statuses.unpin, status["id"]) 140 | assert_ok(result) 141 | assert result.stdout.strip() == "✓ Status unpinned" 142 | 143 | status = api.fetch_status(app, user, status["id"]).json() 144 | assert not status["pinned"] 145 | 146 | 147 | def test_pin_json(app, user, run): 148 | status = api.post_status(app, user, "foo").json() 149 | assert not status["pinned"] 150 | 151 | result = run(cli.statuses.pin, status["id"], "--json") 152 | assert_ok(result) 153 | 154 | result = json.loads(result.stdout) 155 | assert result["pinned"] is True 156 | assert result["id"] == status["id"] 157 | 158 | result = run(cli.statuses.unpin, status["id"], "--json") 159 | assert_ok(result) 160 | 161 | result = json.loads(result.stdout) 162 | assert result["pinned"] is False 163 | assert result["id"] == status["id"] 164 | 165 | 166 | def test_bookmark(app, user, run): 167 | status = api.post_status(app, user, "foo").json() 168 | assert not status["bookmarked"] 169 | 170 | result = run(cli.statuses.bookmark, status["id"]) 171 | assert_ok(result) 172 | assert result.stdout.strip() == "✓ Status bookmarked" 173 | 174 | status = api.fetch_status(app, user, status["id"]).json() 175 | assert status["bookmarked"] 176 | 177 | result = run(cli.statuses.unbookmark, status["id"]) 178 | assert_ok(result) 179 | assert result.stdout.strip() == "✓ Status unbookmarked" 180 | 181 | status = api.fetch_status(app, user, status["id"]).json() 182 | assert not status["bookmarked"] 183 | 184 | 185 | def test_bookmark_json(app, user, run): 186 | status = api.post_status(app, user, "foo").json() 187 | assert not status["bookmarked"] 188 | 189 | result = run(cli.statuses.bookmark, status["id"], "--json") 190 | assert_ok(result) 191 | 192 | result = json.loads(result.stdout) 193 | assert result["id"] == status["id"] 194 | assert result["bookmarked"] is True 195 | 196 | result = run(cli.statuses.unbookmark, status["id"], "--json") 197 | assert_ok(result) 198 | 199 | result = json.loads(result.stdout) 200 | assert result["id"] == status["id"] 201 | assert result["bookmarked"] is False 202 | -------------------------------------------------------------------------------- /toot/tui/compose.py: -------------------------------------------------------------------------------- 1 | import urwid 2 | import logging 3 | 4 | from .constants import VISIBILITY_OPTIONS 5 | from .widgets import Button, EditBox 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class StatusComposer(urwid.Frame): 11 | """ 12 | UI for composing or editing a status message. 13 | 14 | To edit a status, provide the original status in 'edit', and optionally 15 | provide the status source (from the /status/:id/source API endpoint) in 16 | 'source'; this should have at least a 'text' member, and optionally 17 | 'spoiler_text'. If source is not provided, the formatted HTML will be 18 | presented to the user for editing. 19 | """ 20 | signals = ["close", "post"] 21 | 22 | def __init__(self, max_chars, username, visibility, in_reply_to=None, 23 | edit=None, source=None): 24 | self.in_reply_to = in_reply_to 25 | self.max_chars = max_chars 26 | self.username = username 27 | self.edit = edit 28 | 29 | self.cw_edit = None 30 | self.cw_add_button = Button("Add content warning", 31 | on_press=self.add_content_warning) 32 | self.cw_remove_button = Button("Remove content warning", 33 | on_press=self.remove_content_warning) 34 | 35 | if edit: 36 | if source is None: 37 | text = edit.data["content"] 38 | else: 39 | text = source.get("text", edit.data["content"]) 40 | 41 | if 'spoiler_text' in source: 42 | self.cw_edit = EditBox(multiline=True, allow_tab=True, 43 | edit_text=source['spoiler_text']) 44 | 45 | self.visibility = edit.data["visibility"] 46 | 47 | else: # not edit 48 | text = self.get_initial_text(in_reply_to) 49 | self.visibility = ( 50 | in_reply_to.visibility if in_reply_to else visibility 51 | ) 52 | 53 | self.content_edit = EditBox( 54 | edit_text=text, edit_pos=len(text), multiline=True, allow_tab=True) 55 | urwid.connect_signal(self.content_edit.edit, "change", self.text_changed) 56 | 57 | self.char_count = urwid.Text(["0/{}".format(max_chars)]) 58 | 59 | self.visibility_button = Button("Visibility: {}".format(self.visibility), 60 | on_press=self.choose_visibility) 61 | 62 | self.post_button = Button("Edit" if edit else "Post", on_press=self.post) 63 | self.cancel_button = Button("Cancel", on_press=self.close) 64 | 65 | contents = list(self.generate_list_items()) 66 | self.walker = urwid.SimpleListWalker(contents) 67 | self.listbox = urwid.ListBox(self.walker) 68 | return super().__init__(self.listbox) 69 | 70 | def get_initial_text(self, in_reply_to): 71 | if not in_reply_to: 72 | return "" 73 | 74 | text = '' if in_reply_to.is_mine else '@{} '.format(in_reply_to.original.account) 75 | mentions = ['@{}'.format(m["acct"]) for m in in_reply_to.mentions if m["acct"] != self.username] 76 | if mentions: 77 | text += '\n\n{}'.format(' '.join(mentions)) 78 | 79 | return text 80 | 81 | def text_changed(self, edit, text): 82 | count = self.max_chars - len(text) 83 | text = "{}/{}".format(count, self.max_chars) 84 | color = "warning" if count < 0 else "" 85 | self.char_count.set_text((color, text)) 86 | 87 | def generate_list_items(self): 88 | if self.in_reply_to: 89 | yield urwid.Text(("dim", "Replying to {}".format(self.in_reply_to.original.account))) 90 | yield urwid.AttrWrap(urwid.Divider("-"), "dim") 91 | 92 | yield urwid.Text("Status message") 93 | yield self.content_edit 94 | yield self.char_count 95 | yield urwid.Divider() 96 | 97 | if self.cw_edit: 98 | yield urwid.Text("Content warning") 99 | yield self.cw_edit 100 | yield urwid.Divider() 101 | yield self.cw_remove_button 102 | else: 103 | yield self.cw_add_button 104 | 105 | yield self.visibility_button 106 | yield self.post_button 107 | yield self.cancel_button 108 | 109 | def refresh(self): 110 | self.walker = urwid.SimpleListWalker(list(self.generate_list_items())) 111 | self.listbox.body = self.walker 112 | 113 | def choose_visibility(self, *args): 114 | list_items = [urwid.Text("Choose status visibility:")] 115 | for visibility, caption, description in VISIBILITY_OPTIONS: 116 | text = "{} - {}".format(caption, description) 117 | button = Button(text, on_press=self.set_visibility, user_data=visibility) 118 | list_items.append(button) 119 | 120 | self.walker = urwid.SimpleListWalker(list_items) 121 | self.listbox.body = self.walker 122 | 123 | # Initially focus currently chosen visibility 124 | focus_map = {v[0]: n + 1 for n, v in enumerate(VISIBILITY_OPTIONS)} 125 | focus = focus_map.get(self.visibility, 1) 126 | self.walker.set_focus(focus) 127 | 128 | def set_visibility(self, widget, visibility): 129 | self.visibility = visibility 130 | self.visibility_button.set_label("Visibility: {}".format(self.visibility)) 131 | self.refresh() 132 | self.walker.set_focus(7 if self.cw_edit else 4) 133 | 134 | def add_content_warning(self, button): 135 | self.cw_edit = EditBox(multiline=True, allow_tab=True) 136 | self.refresh() 137 | self.walker.set_focus(4) 138 | 139 | def remove_content_warning(self, button): 140 | self.cw_edit = None 141 | self.refresh() 142 | self.walker.set_focus(3) 143 | 144 | def set_error_message(self, msg): 145 | self.footer = urwid.Text(("footer_message_error", msg)) 146 | 147 | def clear_error_message(self): 148 | self.footer = None 149 | 150 | def post(self, button): 151 | self.clear_error_message() 152 | 153 | # Don't lstrip content to avoid removing intentional leading whitespace 154 | # However, do strip both sides to check if there is any content there 155 | content = self.content_edit.edit_text.rstrip() 156 | content = None if not content.strip() else content 157 | 158 | warning = self.cw_edit.edit_text.rstrip() if self.cw_edit else "" 159 | warning = None if not warning.strip() else warning 160 | 161 | if not content: 162 | self.set_error_message("Cannot post an empty message") 163 | return 164 | 165 | in_reply_to_id = self.in_reply_to.id if self.in_reply_to else None 166 | self._emit("post", content, warning, self.visibility, in_reply_to_id) 167 | 168 | def close(self, button): 169 | self._emit("close") 170 | -------------------------------------------------------------------------------- /toot/cli/timelines.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import click 3 | 4 | from toot import api 5 | from toot.cli import NOTIFICATION_TYPE_CHOICES, InstanceParamType, cli, get_context, pass_context, Context, json_option 6 | from typing import Optional, Tuple 7 | from toot.cli.validators import validate_instance 8 | 9 | from toot.entities import Notification, Status, from_dict 10 | from toot.output import print_notifications, print_timeline, print_warning 11 | 12 | 13 | @cli.command() 14 | @click.option( 15 | "--instance", "-i", 16 | type=InstanceParamType(), 17 | callback=validate_instance, 18 | help="""Domain or base URL of the instance from which to read, 19 | e.g. 'mastodon.social' or 'https://mastodon.social'""", 20 | ) 21 | @click.option("--account", "-a", help="Show account timeline") 22 | @click.option("--list", help="Show list timeline") 23 | @click.option("--tag", "-t", help="Show hashtag timeline") 24 | @click.option("--public", "-p", is_flag=True, help="Show public timeline") 25 | @click.option( 26 | "--local", "-l", is_flag=True, 27 | help="Show only statuses from the local instance (public and tag timelines only)" 28 | ) 29 | @click.option( 30 | "--reverse", "-r", is_flag=True, 31 | help="Reverse the order of the shown timeline (new posts at the bottom)" 32 | ) 33 | @click.option( 34 | "--once", "-1", is_flag=True, 35 | help="Only show the first toots, do not prompt to continue" 36 | ) 37 | @click.option( 38 | "--count", "-c", type=int, default=10, 39 | help="Number of posts per page (max 20)" 40 | ) 41 | def timeline( 42 | instance: Optional[str], 43 | account: Optional[str], 44 | list: Optional[str], 45 | tag: Optional[str], 46 | public: bool, 47 | local: bool, 48 | reverse: bool, 49 | once: bool, 50 | count: int, 51 | ): 52 | """Show timelines (deprecated, use `toot timelines` instead) 53 | 54 | By default shows the home timeline. 55 | """ 56 | if len([arg for arg in [tag, list, public, account] if arg]) > 1: 57 | raise click.ClickException("Only one of --public, --tag, --account, or --list can be used at one time.") 58 | 59 | if local and not (public or tag): 60 | raise click.ClickException("The --local option is only valid alongside --public or --tag.") 61 | 62 | if instance and not (public or tag): 63 | raise click.ClickException("The --instance option is only valid alongside --public or --tag.") 64 | 65 | if public and instance: 66 | generator = api.anon_public_timeline_generator(instance, local, count) 67 | elif tag and instance: 68 | generator = api.anon_tag_timeline_generator(instance, tag, local, count) 69 | else: 70 | ctx = get_context() 71 | list_id = _get_list_id(ctx, list) 72 | 73 | """Show recent statuses in a timeline""" 74 | generator = api.get_timeline_generator( 75 | ctx.app, 76 | ctx.user, 77 | account=account, 78 | list_id=list_id, 79 | tag=tag, 80 | public=public, 81 | local=local, 82 | limit=count, 83 | ) 84 | 85 | _show_timeline(generator, reverse, once) 86 | 87 | 88 | @cli.command() 89 | @click.option( 90 | "--reverse", "-r", is_flag=True, 91 | help="Reverse the order of the shown timeline (new posts at the bottom)" 92 | ) 93 | @click.option( 94 | "--once", "-1", is_flag=True, 95 | help="Only show the first toots, do not prompt to continue" 96 | ) 97 | @click.option( 98 | "--count", "-c", type=int, default=10, 99 | help="Number of posts per page (max 20)" 100 | ) 101 | @pass_context 102 | def bookmarks( 103 | ctx: Context, 104 | reverse: bool, 105 | once: bool, 106 | count: int, 107 | ): 108 | """Show recent statuses in a timeline""" 109 | generator = api.bookmark_timeline_generator(ctx.app, ctx.user, limit=count) 110 | _show_timeline(generator, reverse, once) 111 | 112 | 113 | @cli.command() 114 | @click.option( 115 | "--clear", is_flag=True, 116 | help="Dismiss all notifications and exit" 117 | ) 118 | @click.option( 119 | "--reverse", "-r", is_flag=True, 120 | help="Reverse the order of the shown notifications (newest on top)" 121 | ) 122 | @click.option( 123 | "--type", "-t", "types", 124 | type=click.Choice(NOTIFICATION_TYPE_CHOICES), 125 | multiple=True, 126 | help="Types to include in the result, can be specified multiple times" 127 | ) 128 | @click.option( 129 | "--exclude-type", "-e", "exclude_types", 130 | type=click.Choice(NOTIFICATION_TYPE_CHOICES), 131 | multiple=True, 132 | help="Types to exclude in the result, can be specified multiple times" 133 | ) 134 | @click.option( 135 | "--mentions", "-m", is_flag=True, 136 | help="Show only mentions (same as --type mention, overrides --type, DEPRECATED)" 137 | ) 138 | @json_option 139 | @pass_context 140 | def notifications( 141 | ctx: Context, 142 | clear: bool, 143 | reverse: bool, 144 | mentions: bool, 145 | types: Tuple[str], 146 | exclude_types: Tuple[str], 147 | json: bool, 148 | ): 149 | """Show notifications""" 150 | if clear: 151 | api.clear_notifications(ctx.app, ctx.user) 152 | click.secho("✓ Notifications cleared", fg="green") 153 | return 154 | 155 | if mentions: 156 | print_warning("`--mentions` option is deprecated in favour of `--type mentions`") 157 | types = ("mention",) 158 | 159 | response = api.get_notifications(ctx.app, ctx.user, types=types, exclude_types=exclude_types) 160 | 161 | if json: 162 | if reverse: 163 | print_warning("--reverse is not supported alongside --json, ignoring") 164 | click.echo(response.text) 165 | return 166 | 167 | notifications = [from_dict(Notification, n) for n in response.json()] 168 | if reverse: 169 | notifications = reversed(notifications) 170 | 171 | if notifications: 172 | print_notifications(notifications) 173 | else: 174 | click.echo("You have no notifications") 175 | 176 | 177 | def _show_timeline(generator, reverse, once): 178 | while True: 179 | try: 180 | items = next(generator) 181 | except StopIteration: 182 | click.echo("That's all folks.") 183 | return 184 | 185 | if reverse: 186 | items = reversed(items) 187 | 188 | statuses = [from_dict(Status, item) for item in items] 189 | print_timeline(statuses) 190 | 191 | if once or not sys.stdout.isatty(): 192 | break 193 | 194 | char = input("\nContinue? [Y/n] ") 195 | if char.lower() == "n": 196 | break 197 | 198 | 199 | def _get_list_id(ctx: Context, value: Optional[str]) -> Optional[str]: 200 | if not value: 201 | return None 202 | 203 | lists = api.get_lists(ctx.app, ctx.user) 204 | for list in lists: 205 | if list["id"] == value or list["title"] == value: 206 | return list["id"] 207 | -------------------------------------------------------------------------------- /tests/integration/test_auth.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict 2 | from unittest import mock 3 | from unittest.mock import MagicMock 4 | 5 | from toot import User, cli 6 | from tests.integration.conftest import PASSWORD, Run, assert_ok 7 | 8 | # TODO: figure out how to test login 9 | 10 | 11 | EMPTY_CONFIG: Dict[Any, Any] = { 12 | "apps": {}, 13 | "users": {}, 14 | "active_user": None 15 | } 16 | 17 | SAMPLE_CONFIG = { 18 | "active_user": "frank@foo.social", 19 | "apps": { 20 | "foo.social": { 21 | "base_url": "http://foo.social", 22 | "client_id": "123", 23 | "client_secret": "123", 24 | "instance": "foo.social" 25 | }, 26 | "bar.social": { 27 | "base_url": "http://bar.social", 28 | "client_id": "123", 29 | "client_secret": "123", 30 | "instance": "bar.social" 31 | }, 32 | }, 33 | "users": { 34 | "frank@foo.social": { 35 | "access_token": "123", 36 | "instance": "foo.social", 37 | "username": "frank" 38 | }, 39 | "frank@bar.social": { 40 | "access_token": "123", 41 | "instance": "bar.social", 42 | "username": "frank" 43 | }, 44 | } 45 | } 46 | 47 | 48 | def test_env(run: Run): 49 | result = run(cli.auth.env) 50 | assert_ok(result) 51 | assert "toot" in result.stdout 52 | assert "Python" in result.stdout 53 | 54 | 55 | @mock.patch("toot.config.load_config") 56 | def test_auth_empty(load_config: MagicMock, run: Run): 57 | load_config.return_value = EMPTY_CONFIG 58 | result = run(cli.auth.auth) 59 | assert_ok(result) 60 | assert result.stdout.strip() == "You are not logged in to any accounts" 61 | 62 | 63 | @mock.patch("toot.config.load_config") 64 | def test_auth_full(load_config: MagicMock, run: Run): 65 | load_config.return_value = SAMPLE_CONFIG 66 | result = run(cli.auth.auth) 67 | assert_ok(result) 68 | assert result.stdout.strip().startswith("Authenticated accounts:") 69 | assert "frank@foo.social" in result.stdout 70 | assert "frank@bar.social" in result.stdout 71 | 72 | 73 | # Saving config is mocked so we don't mess up our local config 74 | # TODO: could this be implemented using an auto-use fixture so we have it always 75 | # mocked? 76 | @mock.patch("toot.config.load_app") 77 | @mock.patch("toot.config.save_app") 78 | @mock.patch("toot.config.save_user") 79 | def test_login_cli( 80 | save_user: MagicMock, 81 | save_app: MagicMock, 82 | load_app: MagicMock, 83 | user: User, 84 | run: Run, 85 | ): 86 | load_app.return_value = None 87 | 88 | result = run( 89 | cli.auth.login_cli, 90 | "--instance", "http://localhost:3000", 91 | "--email", f"{user.username}@example.com", 92 | "--password", PASSWORD, 93 | ) 94 | assert_ok(result) 95 | assert "✓ Successfully logged in." in result.stdout 96 | 97 | save_app.assert_called_once() 98 | (app,) = save_app.call_args.args 99 | assert app.instance == "localhost:3000" 100 | assert app.base_url == "http://localhost:3000" 101 | assert app.client_id 102 | assert app.client_secret 103 | 104 | save_user.assert_called_once() 105 | (new_user,) = save_user.call_args.args 106 | assert new_user.instance == "localhost:3000" 107 | assert new_user.username == user.username 108 | # access token will be different since this is a new login 109 | assert new_user.access_token and new_user.access_token != user.access_token 110 | assert save_user.call_args.kwargs == {"activate": True} 111 | 112 | 113 | @mock.patch("toot.config.load_app") 114 | @mock.patch("toot.config.save_app") 115 | @mock.patch("toot.config.save_user") 116 | def test_login_cli_wrong_password( 117 | save_user: MagicMock, 118 | save_app: MagicMock, 119 | load_app: MagicMock, 120 | user: User, 121 | run: Run, 122 | ): 123 | load_app.return_value = None 124 | 125 | result = run( 126 | cli.auth.login_cli, 127 | "--instance", "http://localhost:3000", 128 | "--email", f"{user.username}@example.com", 129 | "--password", "wrong password", 130 | ) 131 | assert result.exit_code == 1 132 | assert result.stderr.strip() == "Error: Login failed" 133 | 134 | save_app.assert_called_once() 135 | (app,) = save_app.call_args.args 136 | assert app.instance == "localhost:3000" 137 | assert app.base_url == "http://localhost:3000" 138 | assert app.client_id 139 | assert app.client_secret 140 | 141 | save_user.assert_not_called() 142 | 143 | 144 | @mock.patch("toot.config.load_config") 145 | @mock.patch("toot.config.delete_user") 146 | def test_logout(delete_user: MagicMock, load_config: MagicMock, run: Run): 147 | load_config.return_value = SAMPLE_CONFIG 148 | 149 | result = run(cli.auth.logout, "frank@foo.social") 150 | assert_ok(result) 151 | assert result.stdout.strip() == "✓ Account frank@foo.social logged out" 152 | delete_user.assert_called_once_with(User("foo.social", "frank", "123")) 153 | 154 | 155 | @mock.patch("toot.config.load_config") 156 | def test_logout_not_logged_in(load_config: MagicMock, run: Run): 157 | load_config.return_value = EMPTY_CONFIG 158 | 159 | result = run(cli.auth.logout) 160 | assert result.exit_code == 1 161 | assert result.stderr.strip() == "Error: You're not logged into any accounts" 162 | 163 | 164 | @mock.patch("toot.config.load_config") 165 | def test_logout_account_not_specified(load_config: MagicMock, run: Run): 166 | load_config.return_value = SAMPLE_CONFIG 167 | 168 | result = run(cli.auth.logout) 169 | assert result.exit_code == 1 170 | assert result.stderr.startswith("Error: Specify account to log out") 171 | 172 | 173 | @mock.patch("toot.config.load_config") 174 | def test_logout_account_does_not_exist(load_config: MagicMock, run: Run): 175 | load_config.return_value = SAMPLE_CONFIG 176 | 177 | result = run(cli.auth.logout, "banana") 178 | assert result.exit_code == 1 179 | assert result.stderr.startswith("Error: Account not found") 180 | 181 | 182 | @mock.patch("toot.config.load_config") 183 | @mock.patch("toot.config.activate_user") 184 | def test_activate(activate_user: MagicMock, load_config: MagicMock, run: Run): 185 | load_config.return_value = SAMPLE_CONFIG 186 | 187 | result = run(cli.auth.activate, "frank@foo.social") 188 | assert_ok(result) 189 | assert result.stdout.strip() == "✓ Account frank@foo.social activated" 190 | activate_user.assert_called_once_with(User("foo.social", "frank", "123")) 191 | 192 | 193 | @mock.patch("toot.config.load_config") 194 | def test_activate_not_logged_in(load_config: MagicMock, run: Run): 195 | load_config.return_value = EMPTY_CONFIG 196 | 197 | result = run(cli.auth.activate) 198 | assert result.exit_code == 1 199 | assert result.stderr.strip() == "Error: You're not logged into any accounts" 200 | 201 | 202 | @mock.patch("toot.config.load_config") 203 | def test_activate_account_not_given(load_config: MagicMock, run: Run): 204 | load_config.return_value = SAMPLE_CONFIG 205 | 206 | result = run(cli.auth.activate) 207 | assert result.exit_code == 1 208 | assert result.stderr.startswith("Error: Specify account to activate") 209 | 210 | 211 | @mock.patch("toot.config.load_config") 212 | def test_activate_invalid_Account(load_config: MagicMock, run: Run): 213 | load_config.return_value = SAMPLE_CONFIG 214 | 215 | result = run(cli.auth.activate, "banana") 216 | assert result.exit_code == 1 217 | assert result.stderr.startswith("Error: Account not found") 218 | --------------------------------------------------------------------------------