Porównaj commity

...

75 Commity

Autor SHA1 Wiadomość Data
Ivan Habunek b97a995dc4
Add assert_ok helper 2024-04-21 10:03:10 +02:00
Ivan Habunek 5cd25e2ce2
Tweak pyright config 2024-04-20 13:36:35 +02:00
Ivan Habunek aa1f2a5bc8
Start documenting testing 2024-04-20 13:27:42 +02:00
Ivan Habunek 4996da61e5
Add python version for pyright 2024-04-15 08:30:28 +02:00
Ivan Habunek 87acfb8ef4
Fix broken build 2024-04-14 09:05:09 +02:00
Ivan Habunek 927fdc3026
Improve types 2024-04-13 15:30:52 +02:00
Ivan Habunek 2ba90fc2d2
Bump python version for vermin 2024-04-13 09:24:58 +02:00
Ivan Habunek 8243dbab34
Add changelog 2024-04-13 09:23:38 +02:00
Ivan Habunek 597dddf76d
Drop typing compat needed for python 3.7 2024-04-13 09:22:57 +02:00
Ivan Habunek b482dc20b4
Drop support for python 3.7 2024-04-13 09:21:41 +02:00
Ivan Habunek 211e501fbc
Update release docs 2024-04-13 09:16:04 +02:00
Ivan Habunek b9c671b5a8
Remove test dependency on psycopg2
No longer using database for testing.
2024-04-13 09:09:59 +02:00
Ivan Habunek 77d8e7d7b5
Use build for packaging 2024-04-13 09:09:17 +02:00
Ivan Habunek 880848fae3
Remove version checks from tag script
Not needed since we're using the version from scm.
2024-04-13 09:06:00 +02:00
Ivan Habunek f54b6ac9d7
Update changelog 2024-04-13 09:04:10 +02:00
Ivan Habunek f925199781
Migrate setup.py to pyproject.toml 2024-04-13 08:49:25 +02:00
Daniel Schwarz 0fc2ec12f5
Display images 2024-04-13 08:28:28 +02:00
Ivan Habunek 07ad41960f
Capitalize visibility 2024-04-08 08:34:56 +02:00
Sandra Snan 07beba8c68
Fix --clear text issue
It's a click flag.
2024-04-08 08:32:05 +02:00
Sandra Snan 7244b2718f
Print visibility in CLI
I went with two spaces before and after but feel free to change that
to whatever! Having the visibility printed this way is pretty useful
for us who mostly read posts through the CLI.
2024-04-08 08:31:19 +02:00
Ivan Habunek 968a516f76
Remove unused helpers 2024-04-06 15:06:59 +02:00
Ivan Habunek 38eca67905
Fix bug in run_with_retries, better types 2024-04-06 15:05:47 +02:00
Luca Matei Pintilie 1d48e64853
Fix version check in case of an empty string
Some mastodon implementations (GoToSocial) will return `version: ""`, in
which case checking for the major version won't work.

This is why an extra check has to be added, and default to 0 as the
"major" version.
2024-04-06 14:56:54 +02:00
Ivan Habunek bf12dbff70
Use a stronger password in tests
gotosocial registration fails with a weak password
2024-04-06 13:15:36 +02:00
Ivan Habunek 4b17e2e586
Merge pull request #473 from danschwarz/corrupt_link_fix
Added safeguards to prevent crashes when rendering corrupt URLs
2024-03-12 14:54:51 +01:00
Daniel Schwarz 20968fe87f Added safeguards to prevent crashes when rendering corrupt URLs 2024-03-09 13:48:33 -05:00
Ivan Habunek 3bac9b2fb6
Add changelog, bump version 2024-03-09 12:12:57 +01:00
Ivan Habunek 3420f1466a
Fix type annotation 2024-03-09 12:12:27 +01:00
Ivan Habunek 3eebbe35c9
Change option to lowercase 2024-03-09 10:16:41 +01:00
Ivan Habunek 4d5ac3cc4e
Don't break if status doesn't have edited_at 2024-03-09 10:13:34 +01:00
Ivan Habunek ee98ce3746
Fix following tests 2024-03-09 09:54:46 +01:00
Ivan Habunek 0cbb8863b3
Start some docs for testing 2024-03-09 09:43:02 +01:00
Ivan Habunek 1709a416b3
Make list printing not break on akkoma 2024-03-09 09:32:38 +01:00
Ivan Habunek f324aa119d
Add List entity 2024-03-09 09:32:04 +01:00
Ivan Habunek 43f51cbbb9
Make tests a bit more robust
By creating a new user we don't need to check if we're following or
blocking them before running the test.
2024-03-09 09:24:00 +01:00
Ivan Habunek 225dfbfb2e
Use alias for types 2024-03-09 09:20:43 +01:00
Ivan Habunek 9ae205c548
Upload media using same user in toot post --using 2024-02-10 18:24:35 +01:00
Ivan Habunek 9875209b30
Improve types 2024-02-10 18:24:35 +01:00
Ivan Habunek 965ffa1312
Remove unused code 2024-02-10 18:24:34 +01:00
Ivan Habunek e1be3a68bb
Merge pull request #466 from danschwarz/scrollbar-update
Updated scroll.py to latest updated version from NomadNet
2024-01-16 11:35:34 +01:00
Daniel Schwarz 0cb2355973 Updated scroll.py to latest updated version from NomadNet
https://github.com/markqvist/NomadNet/blob/master/nomadnet/vendor/Scrollable.py
2024-01-15 21:54:11 -05:00
Ivan Habunek a34831a02b
Merge pull request #460 from danschwarz/roundrect
Converted LineBoxes to RoundedLineBoxes that look nicer
2024-01-10 11:47:57 +01:00
Daniel Schwarz 593c95ea62 Converted LineBoxes to RoundedLineBoxes that look nicer 2024-01-09 23:36:35 -05:00
Ivan Habunek fb36561923
Update contribution guidelines
txt requirements files were replaced by sections in setup.py

fixes #457
2024-01-07 21:13:26 +01:00
Ivan Habunek fcc7f3b017
Merge pull request #456 from kianmeng/fix-typos
Fix typos
2024-01-07 21:09:17 +01:00
Kian-Meng Ang 2d0089893f Fix typos
Found via `codespell -L fo,te,oll`
2024-01-08 02:59:35 +08:00
Ivan Habunek d3d1b0d9a1
Merge pull request #155 from dlax/media-help
Document the [M]edia action
2024-01-04 09:58:14 +01:00
Ivan Habunek fda498d793
Merge pull request #450 from lexiwinter/autoopen-cw
add an option to automatically expand content warnings
2024-01-04 09:47:09 +01:00
Ivan Habunek b4cbeeedeb
Bump version, add changelog 2024-01-02 22:08:06 +01:00
Ivan Habunek 964efc5b4c
Fix bug which causes a crash if palette is not in settings 2024-01-02 22:06:20 +01:00
Ivan Habunek 081bc0459e
Bump version, add changelog 2024-01-02 22:03:31 +01:00
Ivan Habunek 5a26ab4940
Don't access the database in tests
This requires the mastodon instance to be patched so that email
confirmation is not required, but makes it possible to run tests on a
remote instance.
2024-01-02 21:56:51 +01:00
Ivan Habunek db266c563d
Don't set default visibility
This way the visiblility will default to the one in user preferences. By
default this is 'public'.
2024-01-02 21:56:36 +01:00
Ivan Habunek 03035c31a0
Fix warning 2024-01-02 21:02:38 +01:00
Ivan Habunek 7f0692891e
Merge pull request #451 from lexiwinter/edit-toot
tui: allow editing toots
2024-01-02 14:25:20 +01:00
Lexi Winter ec48e8eed8 tui: allow editing toots
Add new [E]dit command to the timeline: opens an existing toot to allow
editing it.  Since this is more or less the same operation as posting a
new toot, extend the StatusComposer view to support this rather than
implementing a new view.

Add a new api method, fetch_status_source(), to implement the
/api/v1/statuses/{id}/source endpoint used to fetch the original post
text.
2024-01-01 14:16:09 +00:00
Ivan Habunek 724f27f860
Remove unused imports 2024-01-01 12:14:15 +01:00
Ivan Habunek d1fe0ca92d
Replace sleeps in tests with retries 2024-01-01 12:12:08 +01:00
Ivan Habunek 301c8d21df
Add test util function for retrying tests 2024-01-01 11:14:04 +01:00
Ivan Habunek 3a147a5ea0
Move Run type alias to conftest
It's only used in tests
2024-01-01 09:52:15 +01:00
Ivan Habunek 84e75347e0
Make palettes work again 2023-12-31 21:29:06 +01:00
Lexi Winter 1ed129f5dd tui: add --always-show-sensitive option
When enabled, this option expands toots with content warnings
automatically, instead of requiring the user to press 'S'.
2023-12-31 18:54:56 +00:00
Lexi Winter f394d78c1e tui: keep CW note after opening toot
Continue to display 'Marked as sensitive' in the toot view even after
the CW has been opened.  This matches the behaviour of other clients,
and is useful to see because it might affect whether you want to boost
the toot or not (for example).
2023-12-31 18:46:01 +00:00
Ivan Habunek 2e55ddbe7e
Merge pull request #454 from lexiwinter/use-preferences-visibility
tui: honour user's default visibility preference
2023-12-31 19:28:15 +01:00
Lexi Winter 5dd53b1b9c tui: honour user's default visibility preference
Mastodon allows the user to configure a default visibility which should
apply to all clients.  This setting is returned by the
/api/v1/preferences method.

Fetch the user preferences when the TUI starts, and use it to set the
default visibility when composing a new toot.  The preference can be
overridden by a new command-line option, toot tui --default-visibility=.
If neither the preference nor the option are set, fall back to
get_default_visibility().
2023-12-31 18:20:47 +00:00
Ivan Habunek 4e55fba15e
Merge pull request #452 from lexiwinter/timeline-reply-fix
tui: fix display glitch for reply icon in timeline
2023-12-31 18:23:01 +01:00
Ivan Habunek 5a2f19a04a
Merge pull request #453 from lexiwinter/mark-edited-toots
tui: show edit date in toot view
2023-12-31 18:15:27 +01:00
Lexi Winter d0f05c7ad9 tui: show edit date in toot view
When viewing a toot which has been edited, show the edit date.

While here, fix a bug where the '*' edit marker in the timeline wouldn't
show for retoots because it was checking the retoot status instead of
the original status.
2023-12-31 16:51:02 +00:00
Lexi Winter 741a306c69 tui: fix display glitch for reply icon in timeline
In some fonts, "⤶" (U+2936 ARROW POINTING DOWNWARDS THEN CURVING
LEFTWARDS) may be a double-width character.  To avoid a display glitch
where this overlaps with the boosted icon, print a space after it.
2023-12-31 16:20:03 +00:00
Ivan Habunek 09b29d2b93
Bump version, update changelog 2023-12-28 19:11:28 +01:00
Ivan Habunek 11aaa1dc29
Reinstate toot post --using option 2023-12-28 19:09:48 +01:00
Ivan Habunek 2e2945822a
Add shell completion for instances 2023-12-28 19:02:19 +01:00
Ivan Habunek 22c9f387a1
Bump version, add changelog 2023-12-28 12:20:43 +01:00
Ivan Habunek ca2912fa78
Add toot --as option to override active account 2023-12-28 12:16:43 +01:00
Denis Laxalde 2199ca18b5 Document the [M]edia action 2020-01-26 11:23:39 +01:00
52 zmienionych plików z 1452 dodań i 555 usunięć

Wyświetl plik

@ -7,12 +7,13 @@ jobs:
runs-on: ubuntu-22.04
strategy:
matrix:
python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"]
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v3
- name: Check out code
uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies

Wyświetl plik

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

Wyświetl plik

@ -3,6 +3,45 @@ Changelog
<!-- Do not edit. This file is automatically generated from changelog.yaml.-->
**0.43.0 (2024-04-13)**
* TUI: Support displaying images (thanks Dan Schwarz)
* Improve GoToSocial compatibility (thanks Luca Matei Pintilie)
* Show visibility in timeline (thanks Sandra Snan)
* Flag `notifications --clear` no longer requires an argument (thanks Sandra
Snan)
* TUI: Fix crash when rendering invalid URLs (thanks Dan Schwarz)
* Migrated to pyproject.toml finally
**0.42.0 (2024-03-09)**
* TUI: Add `toot tui --always-show-sensitive` option (thanks Lexi Winter)
* TUI: Document missing shortcuts (thanks Denis Laxalde)
* TUI: Use rounded boxes for nicer visuals (thanks Dan Schwarz)
* TUI: Don't break if edited_at status field does not exist
**0.41.1 (2024-01-02)**
* Fix a crash in settings parsing code
**0.41.0 (2024-01-02)**
* Honour user's default visibility set in Mastodon preferences instead of always
defaulting to public visibility (thanks Lexi Winter)
* TUI: Add editing toots (thanks Lexi Winter)
* TUI: Fix a bug which made palette config in settings not work
* TUI: Show edit datetime in status detail (thanks Lexi Winter)
**0.40.2 (2023-12-28)**
* Reinstate `toot post --using` option.
* Add shell completion for instances.
**0.40.1 (2023-12-28)**
* Add `toot --as` option to replace `toot post --using`. This now works for all
commands.
**0.40.0 (2023-12-27)**
This release includes a rather extensive change to use the Click library
@ -27,7 +66,7 @@ mostly preserved, except for cases noted below. Please report any issues.
list`, `lists remove` subcommands, deprecate `lists`, `lists_accounts`,
`lists_add`, `lists_create`, `lists_delete`, `lists_remove` commands.
* Add `--json` option to tags and lists commands
* Add `toot --width` option for setting your prefered terminal width
* Add `toot --width` option for setting your preferred terminal width
* Add `--media-viewer` and `--colors` options to `toot tui`. These were
previously accessible only via settings.
* TUI: Fix issue where UI did not render until first input (thanks Urwid devs)
@ -122,7 +161,7 @@ mostly preserved, except for cases noted below. Please report any issues.
* TUI: Hide polls, cards and media attachments for sensitive posts (thanks
Daniel Schwarz)
* TUI: Add bookmarking and bookmark timeline (thanks Daniel Schwarz)
* TUI: Show status visiblity (thanks Lim Ding Wen)
* TUI: Show status visibility (thanks Lim Ding Wen)
* TUI: Reply to original account instead of boosting account (thanks Lim Ding
Wen)
* TUI: Refresh screen after exiting browser, required for text browsers (thanks
@ -150,7 +189,7 @@ mostly preserved, except for cases noted below. Please report any issues.
**0.30.1 (2022-11-30)**
* Remove usage of depreacted `text_url` status field. Fixes posting media
* Remove usage of deprecated `text_url` status field. Fixes posting media
without text.
**0.30.0 (2022-11-29)**
@ -203,7 +242,7 @@ mostly preserved, except for cases noted below. Please report any issues.
(#168)
* Add `--reverse` option to `toot notifications` (#151)
* Fix `toot timeline` to respect `--instance` option
* TUI: Add opton to pin/save tag timelines (#163, thanks @dlax)
* TUI: Add option to pin/save tag timelines (#163, thanks @dlax)
* TUI: Fixed crash on empty timeline (#138, thanks ecs)
**0.26.0 (2020-04-15)**

Wyświetl plik

@ -111,7 +111,7 @@ these rules for you.
#### Run tests before submitting
You can run code and sytle tests by running:
You can run code and style tests by running:
```
make test

Wyświetl plik

@ -1,8 +1,7 @@
.PHONY: clean publish test docs
dist :
python setup.py sdist --formats=gztar,zip
python setup.py bdist_wheel --python-tag=py3
dist:
python -m build
publish :
twine upload dist/*.tar.gz dist/*.whl

Wyświetl plik

@ -1,3 +1,50 @@
0.44.0:
date: TBA
changes:
- "**BREAKING:** Require Python 3.8+"
0.43.0:
date: 2024-04-13
changes:
- "TUI: Support displaying images (thanks Dan Schwarz)"
- "Improve GoToSocial compatibility (thanks Luca Matei Pintilie)"
- "Show visibility in timeline (thanks Sandra Snan)"
- "Flag `notifications --clear` no longer requires an argument (thanks Sandra Snan)"
- "TUI: Fix crash when rendering invalid URLs (thanks Dan Schwarz)"
- "Migrated to pyproject.toml finally"
0.42.0:
date: 2024-03-09
changes:
- "TUI: Add `toot tui --always-show-sensitive` option (thanks Lexi Winter)"
- "TUI: Document missing shortcuts (thanks Denis Laxalde)"
- "TUI: Use rounded boxes for nicer visuals (thanks Dan Schwarz)"
- "TUI: Don't break if edited_at status field does not exist"
0.41.1:
date: 2024-01-02
changes:
- "Fix a crash in settings parsing code"
0.41.0:
date: 2024-01-02
changes:
- "Honour user's default visibility set in Mastodon preferences instead of always defaulting to public visibility (thanks Lexi Winter)"
- "TUI: Add editing toots (thanks Lexi Winter)"
- "TUI: Fix a bug which made palette config in settings not work"
- "TUI: Show edit datetime in status detail (thanks Lexi Winter)"
0.40.2:
date: 2023-12-28
changes:
- "Reinstate `toot post --using` option."
- "Add shell completion for instances."
0.40.1:
date: 2023-12-28
changes:
- "Add `toot --as` option to replace `toot post --using`. This now works for all commands."
0.40.0:
date: 2023-12-27
description: |
@ -17,7 +64,7 @@
- "Add `tags followed`, `tags follow`, and `tags unfollow` sub-commands, deprecate `tags_followed`, `tags_follow`, and `tags tags_unfollow`"
- "Add `lists accounts`, `lists add`, `lists create`, `lists delete`, `lists list`, `lists remove` subcommands, deprecate `lists`, `lists_accounts`, `lists_add`, `lists_create`, `lists_delete`, `lists_remove` commands."
- "Add `--json` option to tags and lists commands"
- "Add `toot --width` option for setting your prefered terminal width"
- "Add `toot --width` option for setting your preferred terminal width"
- "Add `--media-viewer` and `--colors` options to `toot tui`. These were previously accessible only via settings."
- "TUI: Fix issue where UI did not render until first input (thanks Urwid devs)"
@ -109,7 +156,7 @@
- "TUI: Show an error if attemptint to boost a private status (thanks Lim Ding Wen)"
- "TUI: Hide polls, cards and media attachments for sensitive posts (thanks Daniel Schwarz)"
- "TUI: Add bookmarking and bookmark timeline (thanks Daniel Schwarz)"
- "TUI: Show status visiblity (thanks Lim Ding Wen)"
- "TUI: Show status visibility (thanks Lim Ding Wen)"
- "TUI: Reply to original account instead of boosting account (thanks Lim Ding Wen)"
- "TUI: Refresh screen after exiting browser, required for text browsers (thanks Daniel Schwarz)"
- "TUI: Highlight followed tags (thanks Daniel Schwarz)"
@ -137,7 +184,7 @@
0.30.1:
date: 2022-11-30
changes:
- "Remove usage of depreacted `text_url` status field. Fixes posting media without text."
- "Remove usage of deprecated `text_url` status field. Fixes posting media without text."
0.30.0:
date: 2022-11-29
@ -184,7 +231,7 @@
- "TUI: Fix access to public and tag timelines when on private mastodon instances (#168)"
- "Add `--reverse` option to `toot notifications` (#151)"
- "Fix `toot timeline` to respect `--instance` option"
- "TUI: Add opton to pin/save tag timelines (#163, thanks @dlax)"
- "TUI: Add option to pin/save tag timelines (#163, thanks @dlax)"
- "TUI: Fixed crash on empty timeline (#138, thanks ecs)"
0.26.0:

Wyświetl plik

@ -3,6 +3,45 @@ Changelog
<!-- Do not edit. This file is automatically generated from changelog.yaml.-->
**0.43.0 (2024-04-13)**
* TUI: Support displaying images (thanks Dan Schwarz)
* Improve GoToSocial compatibility (thanks Luca Matei Pintilie)
* Show visibility in timeline (thanks Sandra Snan)
* Flag `notifications --clear` no longer requires an argument (thanks Sandra
Snan)
* TUI: Fix crash when rendering invalid URLs (thanks Dan Schwarz)
* Migrated to pyproject.toml finally
**0.42.0 (2024-03-09)**
* TUI: Add `toot tui --always-show-sensitive` option (thanks Lexi Winter)
* TUI: Document missing shortcuts (thanks Denis Laxalde)
* TUI: Use rounded boxes for nicer visuals (thanks Dan Schwarz)
* TUI: Don't break if edited_at status field does not exist
**0.41.1 (2024-01-02)**
* Fix a crash in settings parsing code
**0.41.0 (2024-01-02)**
* Honour user's default visibility set in Mastodon preferences instead of always
defaulting to public visibility (thanks Lexi Winter)
* TUI: Add editing toots (thanks Lexi Winter)
* TUI: Fix a bug which made palette config in settings not work
* TUI: Show edit datetime in status detail (thanks Lexi Winter)
**0.40.2 (2023-12-28)**
* Reinstate `toot post --using` option.
* Add shell completion for instances.
**0.40.1 (2023-12-28)**
* Add `toot --as` option to replace `toot post --using`. This now works for all
commands.
**0.40.0 (2023-12-27)**
This release includes a rather extensive change to use the Click library
@ -27,7 +66,7 @@ mostly preserved, except for cases noted below. Please report any issues.
list`, `lists remove` subcommands, deprecate `lists`, `lists_accounts`,
`lists_add`, `lists_create`, `lists_delete`, `lists_remove` commands.
* Add `--json` option to tags and lists commands
* Add `toot --width` option for setting your prefered terminal width
* Add `toot --width` option for setting your preferred terminal width
* Add `--media-viewer` and `--colors` options to `toot tui`. These were
previously accessible only via settings.
* TUI: Fix issue where UI did not render until first input (thanks Urwid devs)
@ -122,7 +161,7 @@ mostly preserved, except for cases noted below. Please report any issues.
* TUI: Hide polls, cards and media attachments for sensitive posts (thanks
Daniel Schwarz)
* TUI: Add bookmarking and bookmark timeline (thanks Daniel Schwarz)
* TUI: Show status visiblity (thanks Lim Ding Wen)
* TUI: Show status visibility (thanks Lim Ding Wen)
* TUI: Reply to original account instead of boosting account (thanks Lim Ding
Wen)
* TUI: Refresh screen after exiting browser, required for text browsers (thanks
@ -150,7 +189,7 @@ mostly preserved, except for cases noted below. Please report any issues.
**0.30.1 (2022-11-30)**
* Remove usage of depreacted `text_url` status field. Fixes posting media
* Remove usage of deprecated `text_url` status field. Fixes posting media
without text.
**0.30.0 (2022-11-29)**
@ -203,7 +242,7 @@ mostly preserved, except for cases noted below. Please report any issues.
(#168)
* Add `--reverse` option to `toot notifications` (#151)
* Fix `toot timeline` to respect `--instance` option
* TUI: Add opton to pin/save tag timelines (#163, thanks @dlax)
* TUI: Add option to pin/save tag timelines (#163, thanks @dlax)
* TUI: Fixed crash on empty timeline (#138, thanks ecs)
**0.26.0 (2020-04-15)**

Wyświetl plik

@ -80,9 +80,7 @@ source _env/bin/activate
# On Windows
_env\bin\activate.bat
pip install --editable .
pip install -r requirements-dev.txt
pip install -r requirements-test.txt
pip install --editable ".[dev,test]"
```
While the virtual env is active, running `toot` will execute the one you checked
@ -118,7 +116,7 @@ these rules for you.
#### Run tests before submitting
You can run code and sytle tests by running:
You can run code and style tests by running:
```
make test

Wyświetl plik

@ -5,13 +5,11 @@ This document is a checklist for creating a toot release.
Currently the process is pretty manual and would benefit from automatization.
Bump & tag version
------------------
Make docs and tag version
-------------------------
* Update the version number in `setup.py`
* Update the version number in `toot/__init__.py`
* Update `changelog.yaml` with the release notes & date
* Run `make changelog` to generate a human readable changelog
* Run `make docs` to generate changelog and update docs
* Commit the changes
* Run `./scripts/tag_version <version>` to tag a release in git
* Run `git push --follow-tags` to upload changes and tag to GitHub

Wyświetl plik

@ -11,7 +11,7 @@ Toot will look for the settings file at:
* `~/.config/toot/settings.toml` (Linux & co.)
* `%APPDATA%\toot\settings.toml` (Windows)
Toot will respect the `XDG_CONFIG_HOME` environement variable if it's set and
Toot will respect the `XDG_CONFIG_HOME` environment variable if it's set and
look for the settings file in `$XDG_CONFIG_HOME/toot` instead of
`~/.config/toot`.
@ -82,7 +82,7 @@ By default, TUI operates in 16-color mode which can be changed by setting the
* `16777216` (24 bit)
TUI defines a list of colors which can be customized, currently they can be seen
[in the source code](https://github.com/ihabunek/toot/blob/master/toot/tui/constants.py). They can be overriden in the `[tui.palette]` section.
[in the source code](https://github.com/ihabunek/toot/blob/master/toot/tui/constants.py). They can be overridden in the `[tui.palette]` section.
Each color is defined as a list of upto 5 values:

56
docs/testing.md 100644
Wyświetl plik

@ -0,0 +1,56 @@
# Running toot tests
## Mastodon
Clone mastodon repo and check out the tag you want to test:
```
git clone https://github.com/mastodon/mastodon
cd mastodon
git checkout v4.2.8
```
Set up the required Ruby version using [ASDF](https://asdf-vm.com/). The
required version is listed in `.ruby-version`.
```
asdf install ruby 3.2.3
asdf local ruby 3.2.3
```
Install and set up database:
```
bundle install
yarn install
rails db:setup
```
Patch code so users are auto-approved:
```
curl https://paste.sr.ht/blob/7c6e08bbacf3da05366b3496b3f24dd03d60bd6d | git am
```
Open registrations:
```
bin/tootctl settings registration open
```
Install foreman to run the thing:
```
gem install foreman
```
Start the server:
```
foreman start
```
## Pleroma
https://docs-develop.pleroma.social/backend/development/setting_up_pleroma_dev/

85
pyproject.toml 100644
Wyświetl plik

@ -0,0 +1,85 @@
[build-system]
requires = ["setuptools>=64", "setuptools_scm>=8"]
build-backend = "setuptools.build_meta"
[project]
name = "toot"
authors = [{ name="Ivan Habunek", email="ivan@habunek.com" }]
description = "Mastodon CLI client"
readme = "README.rst"
license = { file="LICENSE" }
requires-python = ">=3.8"
dynamic = ["version"]
classifiers = [
"Environment :: Console :: Curses",
"Environment :: Console",
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
]
dependencies = [
"beautifulsoup4>=4.5.0,<5.0",
"click~=8.1",
"requests>=2.13,<3.0",
"tomlkit>=0.10.0,<1.0",
"urwid>=2.0.0,<3.0",
"wcwidth>=0.1.7",
]
[project.optional-dependencies]
# Required to display images in the TUI
images = [
"pillow>=9.5.0",
"term-image==0.7.0",
]
# Required to display rich text in the TUI
richtext = [
"urwidgets>=0.1,<0.2"
]
test = [
"flake8",
"pytest",
"pytest-xdist[psutil]",
"setuptools",
"vermin",
"typing-extensions",
"pillow>=9.5.0",
]
dev = [
"build",
"flake8",
"mypy",
"pyright",
"pyyaml",
"textual-dev",
"twine",
"types-beautifulsoup4",
"vermin",
]
[project.urls]
"Homepage" = "https://toot.bezdomni.net"
"Source" = "https://github.com/ihabunek/toot/"
[project.scripts]
toot = "toot.cli:cli"
[tool.setuptools]
packages=[
"toot",
"toot.cli",
"toot.tui",
"toot.tui.richtext",
"toot.utils"
]
[tool.setuptools_scm]
[tool.pyright]
typeCheckingMode = "strict"
pythonVersion = "3.8"

Wyświetl plik

@ -16,7 +16,6 @@ import toot
from datetime import date
from os import path
from pkg_resources import get_distribution
path = path.join(path.dirname(path.dirname(path.abspath(__file__))), "changelog.yaml")
with open(path, "r") as f:
@ -33,15 +32,6 @@ if not changelog_item:
print(f"Version `{version}` not found in changelog.", file=sys.stderr)
sys.exit(1)
if toot.__version__ != version:
print(f"toot.__version__ is `{toot.__version__}`, expected {version}.", file=sys.stderr)
sys.exit(1)
dist_version = get_distribution('toot').version
if dist_version != version:
print(f"Version in setup.py is `{dist_version}`, expected {version}.", file=sys.stderr)
sys.exit(1)
release_date = changelog_item["date"]
description = changelog_item.get("description")
changes = changelog_item["changes"]

Wyświetl plik

@ -1,70 +0,0 @@
#!/usr/bin/env python
from setuptools import setup
long_description = """
Toot is a CLI and TUI tool for interacting with Mastodon instances from the
command line.
Allows posting text and media to the timeline, searching, following, muting
and blocking accounts and other actions.
"""
setup(
name='toot',
version='0.40.0',
description='Mastodon CLI client',
long_description=long_description.strip(),
author='Ivan Habunek',
author_email='ivan@habunek.com',
url='https://github.com/ihabunek/toot/',
project_urls={
'Documentation': 'https://toot.bezdomni.net/',
'Issue tracker': 'https://github.com/ihabunek/toot/issues/',
},
keywords='mastodon toot',
license='GPLv3',
classifiers=[
'Development Status :: 4 - Beta',
'Environment :: Console :: Curses',
'Environment :: Console',
'License :: OSI Approved :: GNU General Public License v3 (GPLv3)',
'Programming Language :: Python :: 3',
],
packages=['toot', 'toot.cli', 'toot.tui', 'toot.tui.richtext', 'toot.utils'],
python_requires=">=3.7",
install_requires=[
"click~=8.1",
"requests>=2.13,<3.0",
"beautifulsoup4>=4.5.0,<5.0",
"wcwidth>=0.1.7",
"urwid>=2.0.0,<3.0",
"tomlkit>=0.10.0,<1.0"
],
extras_require={
# Required to display rich text in the TUI
"richtext": [
"urwidgets>=0.1,<0.2"
],
"dev": [
"coverage",
"pyyaml",
"twine",
"wheel",
],
"test": [
"flake8",
"psycopg2-binary",
"pytest",
"pytest-xdist[psutil]",
"setuptools",
"vermin",
"typing-extensions",
],
},
entry_points={
'console_scripts': [
'toot=toot.cli:cli',
],
}
)

42
tests/README.md 100644
Wyświetl plik

@ -0,0 +1,42 @@
Testing toot
============
This document is WIP.
Mastodon
--------
TODO
Pleroma
-------
TODO
Akkoma
------
Install using the guide here:
https://docs.akkoma.dev/stable/installation/docker_en/
Disable captcha and throttling by adding this to `config/prod.exs`:
```ex
# Disable captcha for testing
config :pleroma, Pleroma.Captcha,
enabled: false
# Disable rate limiting for testing
config :pleroma, :rate_limit,
authentication: nil,
timeline: nil,
search: nil,
app_account_creation: nil,
relations_actions: nil,
relation_id_action: nil,
statuses_actions: nil,
status_id_action: nil,
password_reset: nil,
account_confirmation_resend: nil,
ap_routes: nil
```

Wyświetl plik

@ -9,15 +9,14 @@ your test server and database:
```
export TOOT_TEST_BASE_URL="localhost:3000"
export TOOT_TEST_DATABASE_DSN="dbname=mastodon_development"
```
"""
import json
import re
import os
import psycopg2
import pytest
import re
import typing as t
import uuid
from click.testing import CliRunner, Result
@ -31,8 +30,10 @@ def pytest_configure(config):
toot.settings.DISABLE_SETTINGS = True
# Type alias for run commands
Run = t.Callable[..., Result]
# Mastodon database name, used to confirm user registration without having to click the link
DATABASE_DSN = os.getenv("TOOT_TEST_DATABASE_DSN")
TOOT_TEST_BASE_URL = os.getenv("TOOT_TEST_BASE_URL")
# Toot logo used for testing image upload
@ -40,6 +41,8 @@ TRUMPET = str(Path(__file__).parent.parent.parent / "trumpet.png")
ASSETS_DIR = str(Path(__file__).parent.parent / "assets")
PASSWORD = "83dU29170rjKilKQQwuWhJv3PKnSW59bWx0perjP6i7Nu4rkeh4mRfYuvVLYM3fM"
def create_app(base_url):
instance = api.get_instance(base_url).json()
@ -51,18 +54,10 @@ def register_account(app: App):
username = str(uuid.uuid4())[-10:]
email = f"{username}@example.com"
response = api.register_account(app, username, email, "password", "en")
confirm_user(email)
response = api.register_account(app, username, email, PASSWORD, "en")
return User(app.instance, username, response["access_token"])
def confirm_user(email):
conn = psycopg2.connect(DATABASE_DSN)
cursor = conn.cursor()
cursor.execute("UPDATE users SET confirmed_at = now() WHERE email = %s;", (email,))
conn.commit()
# ------------------------------------------------------------------------------
# Fixtures
# ------------------------------------------------------------------------------
@ -160,3 +155,8 @@ def posted_status_id(out):
_, _, status_id = match.groups()
return status_id
def assert_ok(result: Result):
if result.exit_code != 0:
raise AssertionError(f"Command failed with exit code {result.exit_code}\nStderr: {result.stderr}")

Wyświetl plik

@ -1,4 +1,5 @@
import json
from tests.integration.conftest import register_account
from toot import App, User, api, cli
from toot.entities import Account, Relationship, from_dict
@ -35,9 +36,8 @@ def test_whois(app: App, friend: User, run):
assert f"@{friend.username}" in result.stdout
def test_following(app: App, user: User, friend: User, friend_id, run):
# Make sure we're not initally following friend
api.unfollow(app, user, friend_id)
def test_following(app: App, user: User, run):
friend = register_account(app)
result = run(cli.accounts.following, user.username)
assert result.exit_code == 0
@ -84,9 +84,8 @@ def test_following_not_found(run):
assert result.stderr.strip() == "Error: Account not found"
def test_following_json(app: App, user: User, friend: User, user_id, friend_id, run_json):
# Make sure we're not initally following friend
api.unfollow(app, user, friend_id)
def test_following_json(app: App, user: User, user_id, run_json):
friend = register_account(app)
result = run_json(cli.accounts.following, user.username, "--json")
assert result == []
@ -96,24 +95,26 @@ def test_following_json(app: App, user: User, friend: User, user_id, friend_id,
result = run_json(cli.accounts.follow, friend.username, "--json")
relationship = from_dict(Relationship, result)
assert relationship.id == friend_id
assert relationship.following is True
[result] = run_json(cli.accounts.following, user.username, "--json")
relationship = from_dict(Relationship, result)
assert relationship.id == friend_id
account = from_dict(Account, result)
assert account.acct == friend.username
# If no account is given defaults to logged in user
[result] = run_json(cli.accounts.following, user.username, "--json")
relationship = from_dict(Relationship, result)
assert relationship.id == friend_id
[result] = run_json(cli.accounts.following, "--json")
account = from_dict(Account, result)
assert account.acct == friend.username
assert relationship.following is True
[result] = run_json(cli.accounts.followers, friend.username, "--json")
assert result["id"] == user_id
account = from_dict(Account, result)
assert account.acct == user.username
result = run_json(cli.accounts.unfollow, friend.username, "--json")
assert result["id"] == friend_id
assert result["following"] is False
relationship = from_dict(Relationship, result)
assert relationship.following is False
result = run_json(cli.accounts.following, user.username, "--json")
assert result == []
@ -200,9 +201,8 @@ def test_mute_json(app: App, user: User, friend: User, run_json, friend_id):
assert result == []
def test_block(app, user, friend, friend_id, run):
# Make sure we're not initially blocking friend
api.unblock(app, user, friend_id)
def test_block(app, user, run):
friend = register_account(app)
result = run(cli.accounts.blocked)
assert result.exit_code == 0

Wyświetl plik

@ -3,7 +3,7 @@ from unittest import mock
from unittest.mock import MagicMock
from toot import User, cli
from toot.cli import Run
from tests.integration.conftest import PASSWORD, Run
# TODO: figure out how to test login
@ -89,7 +89,7 @@ def test_login_cli(
cli.auth.login_cli,
"--instance", "http://localhost:3000",
"--email", f"{user.username}@example.com",
"--password", "password",
"--password", PASSWORD,
)
assert result.exit_code == 0
assert "✓ Successfully logged in." in result.stdout

Wyświetl plik

@ -1,7 +1,7 @@
import json
import time
import pytest
from tests.utils import run_with_retries
from toot import api, cli
from toot.exceptions import NotFoundError
@ -46,11 +46,11 @@ def test_favourite(app, user, run):
assert result.exit_code == 0
assert result.stdout.strip() == "✓ Status unfavourited"
# A short delay is required before the server returns new data
time.sleep(0.2)
status = api.fetch_status(app, user, status["id"]).json()
assert not status["favourited"]
def test_favourited():
nonlocal status
status = api.fetch_status(app, user, status["id"]).json()
assert not status["favourited"]
run_with_retries(test_favourited)
def test_favourite_json(app, user, run):

Wyświetl plik

@ -1,14 +1,14 @@
import pytest
from time import sleep
from uuid import uuid4
from tests.utils import run_with_retries
from toot import api, cli
from toot.entities import from_dict, Status
from tests.integration.conftest import TOOT_TEST_BASE_URL, register_account
# TODO: If fixture is not overriden here, tests fail, not sure why, figure it out
# TODO: If fixture is not overridden here, tests fail, not sure why, figure it out
@pytest.fixture(scope="module")
def user(app):
return register_account(app)
@ -40,16 +40,14 @@ def test_timelines(app, user, other_user, friend_user, friend_list, run):
status2 = _post_status(app, other_user, "#bar")
status3 = _post_status(app, friend_user, "#foo #bar")
# Give mastodon time to process things :/
# Tests fail if this is removed, required delay depends on server speed
sleep(1)
# Home timeline
result = run(cli.timelines.timeline)
assert result.exit_code == 0
assert status1.id in result.stdout
assert status2.id not in result.stdout
assert status3.id in result.stdout
def test_home():
result = run(cli.timelines.timeline)
assert result.exit_code == 0
assert status1.id in result.stdout
assert status2.id not in result.stdout
assert status3.id in result.stdout
run_with_retries(test_home)
# Public timeline
result = run(cli.timelines.timeline, "--public")
@ -166,13 +164,14 @@ def test_notifications(app, user, other_user, run):
text = f"Paging doctor @{user.username}"
status = _post_status(app, other_user, text)
sleep(0.5) # grr
result = run(cli.timelines.notifications)
assert result.exit_code == 0
assert f"@{other_user.username} mentioned you" in result.stdout
assert status.id in result.stdout
assert text in result.stdout
def test_notifications():
result = run(cli.timelines.notifications)
assert result.exit_code == 0
assert f"@{other_user.username} mentioned you" in result.stdout
assert status.id in result.stdout
assert text in result.stdout
run_with_retries(test_notifications)
result = run(cli.timelines.notifications, "--mentions")
assert result.exit_code == 0
@ -186,7 +185,6 @@ def test_notifications_follow(app, user, friend_user, run_as):
assert result.exit_code == 0
assert f"@{user.username} now follows you" in result.stdout
result = run_as(friend_user, cli.timelines.notifications, "--mentions")
assert result.exit_code == 0
assert "now follows you" not in result.stdout

Wyświetl plik

@ -1,8 +1,12 @@
import click
import pytest
import sys
from toot.cli.validators import validate_duration
from toot.wcstring import wc_wrap, trunc, pad, fit_text
from toot.tui.utils import LRUCache
from PIL import Image
from collections import namedtuple
from toot.utils import urlencode_url
@ -207,6 +211,111 @@ def test_duration():
duration("banana")
def test_cache_null():
"""Null dict is null."""
cache = LRUCache(cache_max_bytes=1024)
assert cache.__len__() == 0
Case = namedtuple("Case", ["cache_len", "len", "init"])
img = Image.new('RGB', (100, 100))
img_size = sys.getsizeof(img.tobytes())
@pytest.mark.parametrize(
"case",
[
Case(9, 0, []),
Case(9, 1, [("one", img)]),
Case(9, 2, [("one", img), ("two", img)]),
Case(2, 2, [("one", img), ("two", img)]),
Case(1, 1, [("one", img), ("two", img)]),
],
)
@pytest.mark.parametrize("method", ["assign", "init"])
def test_cache_init(case, method):
"""Check that the # of elements is right, given # given and cache_len."""
if method == "init":
cache = LRUCache(case.init, cache_max_bytes=img_size * case.cache_len)
elif method == "assign":
cache = LRUCache(cache_max_bytes=img_size * case.cache_len)
for (key, val) in case.init:
cache[key] = val
else:
assert False
# length is max(#entries, cache_len)
assert cache.__len__() == case.len
# make sure the first entry is the one ejected
if case.cache_len > 1 and case.init:
assert "one" in cache.keys()
else:
assert "one" not in cache.keys()
@pytest.mark.parametrize("method", ["init", "assign"])
def test_cache_overflow_default(method):
"""Test default overflow logic."""
if method == "init":
cache = LRUCache([("one", img), ("two", img), ("three", img)], cache_max_bytes=img_size * 2)
elif method == "assign":
cache = LRUCache(cache_max_bytes=img_size * 2)
cache["one"] = img
cache["two"] = img
cache["three"] = img
else:
assert False
assert "one" not in cache.keys()
assert "two" in cache.keys()
assert "three" in cache.keys()
@pytest.mark.parametrize("mode", ["get", "set"])
@pytest.mark.parametrize("add_third", [False, True])
def test_cache_lru_overflow(mode, add_third):
img = Image.new('RGB', (100, 100))
img_size = sys.getsizeof(img.tobytes())
"""Test that key access resets LRU logic."""
cache = LRUCache([("one", img), ("two", img)], cache_max_bytes=img_size * 2)
if mode == "get":
dummy = cache["one"]
elif mode == "set":
cache["one"] = img
else:
assert False
if add_third:
cache["three"] = img
assert "one" in cache.keys()
assert "two" not in cache.keys()
assert "three" in cache.keys()
else:
assert "one" in cache.keys()
assert "two" in cache.keys()
assert "three" not in cache.keys()
def test_cache_keyerror():
cache = LRUCache()
with pytest.raises(KeyError):
cache["foo"]
def test_cache_miss_doesnt_eject():
cache = LRUCache([("one", img), ("two", img)], cache_max_bytes=img_size * 3)
with pytest.raises(KeyError):
cache["foo"]
assert len(cache) == 2
assert "one" in cache.keys()
assert "two" in cache.keys()
def test_urlencode_url():
assert urlencode_url("https://www.example.com") == "https://www.example.com"
assert urlencode_url("https://www.example.com/url%20with%20spaces") == "https://www.example.com/url%20with%20spaces"

Wyświetl plik

@ -2,20 +2,28 @@
Helpers for testing.
"""
class MockResponse:
def __init__(self, response_data={}, ok=True, is_redirect=False):
self.response_data = response_data
self.content = response_data
self.ok = ok
self.is_redirect = is_redirect
def raise_for_status(self):
pass
def json(self):
return self.response_data
import time
from typing import Callable, TypeVar
def retval(val):
return lambda *args, **kwargs: val
T = TypeVar("T")
def run_with_retries(fn: Callable[..., T]) -> T:
"""
Run the the given function repeatedly until it finishes without raising an
AssertionError. Sleep a bit between attempts. If the function doesn't
succeed in the given number of tries raises the AssertionError. Used for
tests which should eventually succeed.
"""
# Wait upto 6 seconds with incrementally longer sleeps
delays = [0.1, 0.2, 0.3, 0.4, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5]
for delay in delays:
try:
return fn()
except AssertionError:
time.sleep(delay)
return fn()

Wyświetl plik

@ -3,8 +3,13 @@ import sys
from os.path import join, expanduser
from typing import NamedTuple
from importlib import metadata
__version__ = '0.40.0'
try:
__version__ = metadata.version("toot")
except metadata.PackageNotFoundError:
__version__ = "0.0.0"
class App(NamedTuple):

Wyświetl plik

@ -183,7 +183,7 @@ def post_status(
app,
user,
status,
visibility='public',
visibility=None,
media_ids=None,
sensitive=False,
spoiler_text=None,
@ -230,6 +230,52 @@ def post_status(
return http.post(app, user, '/api/v1/statuses', json=data, headers=headers)
def edit_status(
app,
user,
id,
status,
visibility='public',
media_ids=None,
sensitive=False,
spoiler_text=None,
in_reply_to_id=None,
language=None,
content_type=None,
poll_options=None,
poll_expires_in=None,
poll_multiple=None,
poll_hide_totals=None,
) -> Response:
"""
Edit an existing status
https://docs.joinmastodon.org/methods/statuses/#edit
"""
# Strip keys for which value is None
# Sending null values doesn't bother Mastodon, but it breaks Pleroma
data = drop_empty_values({
'status': status,
'media_ids': media_ids,
'visibility': visibility,
'sensitive': sensitive,
'in_reply_to_id': in_reply_to_id,
'language': language,
'content_type': content_type,
'spoiler_text': spoiler_text,
})
if poll_options:
data["poll"] = {
"options": poll_options,
"expires_in": poll_expires_in,
"multiple": poll_multiple,
"hide_totals": poll_hide_totals,
}
return http.put(app, user, f"/api/v1/statuses/{id}", json=data)
def fetch_status(app, user, id):
"""
Fetch a single status
@ -238,6 +284,15 @@ def fetch_status(app, user, id):
return http.get(app, user, f"/api/v1/statuses/{id}")
def fetch_status_source(app, user, id):
"""
Fetch the source (original text) for a single status.
This only works on local toots.
https://docs.joinmastodon.org/methods/statuses/#source
"""
return http.get(app, user, f"/api/v1/statuses/{id}/source")
def scheduled_statuses(app, user):
"""
List scheduled statuses
@ -618,6 +673,10 @@ def get_instance(base_url: str) -> Response:
return http.anon_get(url)
def get_preferences(app, user) -> Response:
return http.get(app, user, '/api/v1/preferences')
def get_lists(app, user):
return http.get(app, user, "/api/v1/lists").json()

Wyświetl plik

@ -4,11 +4,13 @@ import os
import sys
import typing as t
from click.testing import Result
from click.shell_completion import CompletionItem
from click.types import StringParamType
from functools import wraps
from toot import App, User, config, __version__
from toot.settings import get_settings
from toot.output import print_warning
from toot.settings import get_settings
if t.TYPE_CHECKING:
import typing_extensions as te
@ -20,7 +22,7 @@ T = t.TypeVar("T")
PRIVACY_CHOICES = ["public", "unlisted", "private"]
VISIBILITY_CHOICES = ["public", "unlisted", "private", "direct"]
IMAGE_FORMAT_CHOICES = ["block", "iterm", "kitty"]
TUI_COLORS = {
"1": 1,
"16": 16,
@ -36,10 +38,6 @@ DURATION_EXAMPLES = """e.g. "1 day", "2 hours 30 minutes", "5 minutes 30
seconds" or any combination of above. Shorthand: "1d", "2h30m", "5m30s\""""
# Type alias for run commands
Run = t.Callable[..., Result]
def get_default_visibility() -> str:
return os.getenv("TOOT_POST_VISIBILITY", "public")
@ -50,13 +48,11 @@ def get_default_map():
commands = settings.get("commands", {})
# TODO: remove in version 1.0
tui_old = settings.get("tui", {})
# Remove palette to avoid triggering warning for still valid [tui.palette] section
tui_old = settings.get("tui", {}).copy()
if "palette" in tui_old:
del tui_old["palette"]
if tui_old:
# TODO: don't show the warning for [toot.palette]
print_warning("Settings section [tui] has been deprecated in favour of [commands.tui].")
tui_new = commands.get("tui", {})
commands["tui"] = {**tui_old, **tui_new}
@ -89,10 +85,38 @@ class TootObj(t.NamedTuple):
"""Data to add to Click context"""
color: bool = True
debug: bool = False
as_user: t.Optional[str] = None
# Pass a context for testing purposes
test_ctx: t.Optional[Context] = None
class AccountParamType(StringParamType):
"""Custom type to add shell completion for account names"""
name = "account"
def shell_complete(self, ctx, param, incomplete: str):
users = config.load_config()["users"].keys()
return [
CompletionItem(u)
for u in users
if u.lower().startswith(incomplete.lower())
]
class InstanceParamType(StringParamType):
"""Custom type to add shell completion for instance domains"""
name = "instance"
def shell_complete(self, ctx, param, incomplete: str):
apps = config.load_config()["apps"]
return [
CompletionItem(i)
for i in apps.keys()
if i.lower().startswith(incomplete.lower())
]
def pass_context(f: "t.Callable[te.Concatenate[Context, P], R]") -> "t.Callable[P, R]":
"""Pass the toot Context as first argument."""
@wraps(f)
@ -110,9 +134,14 @@ def get_context() -> Context:
if obj.test_ctx:
return obj.test_ctx
user, app = config.get_active_user_app()
if not user or not app:
raise click.ClickException("This command requires you to be logged in.")
if obj.as_user:
user, app = config.get_user_app(obj.as_user)
if not user or not app:
raise click.ClickException(f"Account '{obj.as_user}' not found. Run `toot auth` to see available accounts.")
else:
user, app = config.get_active_user_app()
if not user or not app:
raise click.ClickException("This command requires you to be logged in.")
return Context(app, user, obj.color, obj.debug)
@ -129,11 +158,12 @@ json_option = click.option(
@click.option("-w", "--max-width", type=int, default=80, help="Maximum width for content rendered by toot")
@click.option("--debug/--no-debug", default=False, help="Log debug info to stderr")
@click.option("--color/--no-color", default=sys.stdout.isatty(), help="Use ANSI color in output")
@click.option("--as", "as_user", type=AccountParamType(), help="The account to use, overrides the active account.")
@click.version_option(__version__, message="%(prog)s v%(version)s")
@click.pass_context
def cli(ctx: click.Context, max_width: int, color: bool, debug: bool):
def cli(ctx: click.Context, max_width: int, color: bool, debug: bool, as_user: str):
"""Toot is a Mastodon CLI"""
ctx.obj = TootObj(color, debug)
ctx.obj = TootObj(color, debug, as_user)
ctx.color = color
ctx.max_content_width = max_width

Wyświetl plik

@ -2,13 +2,10 @@ import click
import platform
import sys
import webbrowser
from click.shell_completion import CompletionItem
from click.types import StringParamType
from toot import api, config, __version__
from toot.auth import get_or_create_app, login_auth_code, login_username_password
from toot.cli import cli
from toot.cli import AccountParamType, cli
from toot.cli.validators import validate_instance
@ -22,18 +19,6 @@ instance_option = click.option(
)
class AccountParamType(StringParamType):
"""Custom type to add shell completion for account names"""
def shell_complete(self, ctx, param, incomplete: str):
accounts = config.load_config()["users"].keys()
return [
CompletionItem(a)
for a in accounts
if a.lower().startswith(incomplete.lower())
]
@cli.command()
def auth():
"""Show logged in accounts and instances"""

Wyświetl plik

@ -3,6 +3,7 @@ import json as pyjson
from toot import api, config
from toot.cli import Context, cli, pass_context, json_option
from toot.entities import from_dict_list, List
from toot.output import print_list_accounts, print_lists, print_warning
@ -18,7 +19,8 @@ def lists(ctx: click.Context):
if not user or not app:
raise click.ClickException("This command requires you to be logged in.")
lists = api.get_lists(app, user)
data = api.get_lists(app, user)
lists = from_dict_list(List, data)
if lists:
print_lists(lists)
else:
@ -30,12 +32,13 @@ def lists(ctx: click.Context):
@pass_context
def list(ctx: Context, json: bool):
"""List all your lists"""
lists = api.get_lists(ctx.app, ctx.user)
data = api.get_lists(ctx.app, ctx.user)
if json:
click.echo(pyjson.dumps(lists))
click.echo(pyjson.dumps(data))
else:
if lists:
if data:
lists = from_dict_list(List, data)
print_lists(lists)
else:
click.echo("You have no lists defined.")

Wyświetl plik

@ -6,8 +6,8 @@ from datetime import datetime, timedelta, timezone
from time import sleep, time
from typing import BinaryIO, Optional, Tuple
from toot import api
from toot.cli import cli, json_option, pass_context, Context
from toot import api, config
from toot.cli import AccountParamType, cli, json_option, pass_context, Context
from toot.cli import DURATION_EXAMPLES, VISIBILITY_CHOICES
from toot.cli.validators import validate_duration, validate_language
from toot.entities import MediaAttachment, from_dict
@ -40,7 +40,6 @@ from toot.utils.datetime import parse_datetime
"--visibility", "-v",
help="Post visibility",
type=click.Choice(VISIBILITY_CHOICES),
default="public",
)
@click.option(
"--sensitive", "-s",
@ -106,6 +105,11 @@ from toot.utils.datetime import parse_datetime
is_flag=True,
default=False,
)
@click.option(
"-u", "--using",
type=AccountParamType(),
help="The account to use, overrides the active account.",
)
@json_option
@pass_context
def post(
@ -114,7 +118,7 @@ def post(
media: Tuple[str],
descriptions: Tuple[str],
thumbnails: Tuple[str],
visibility: str,
visibility: Optional[str],
sensitive: bool,
spoiler_text: Optional[str],
reply_to: Optional[str],
@ -127,13 +131,21 @@ def post(
poll_expires_in: int,
poll_multiple: bool,
poll_hide_totals: bool,
json: bool
json: bool,
using: str
):
"""Post a new status"""
if len(media) > 4:
raise click.ClickException("Cannot attach more than 4 files.")
media_ids = _upload_media(ctx.app, ctx.user, media, descriptions, thumbnails)
if using:
user, app = config.get_user_app(using)
if not user or not app:
raise click.ClickException(f"Account '{using}' not found. Run `toot auth` to see available accounts.")
else:
user, app = ctx.user, ctx.app
media_ids = _upload_media(app, user, media, descriptions, thumbnails)
status_text = _get_status_text(text, editor, media)
scheduled_at = _get_scheduled_at(scheduled_at, scheduled_in)
@ -141,8 +153,8 @@ def post(
raise click.ClickException("You must specify either text or media to post.")
response = api.post_status(
ctx.app,
ctx.user,
app,
user,
status_text,
visibility=visibility,
media_ids=media_ids,

Wyświetl plik

@ -9,7 +9,7 @@ from toot.cli.validators import validate_instance
from toot.entities import Instance, Status, from_dict, Account
from toot.exceptions import ApiError, ConsoleError
from toot.output import print_account, print_instance, print_search_results, print_status, print_timeline
from toot.cli import cli, get_context, json_option, pass_context, Context
from toot.cli import InstanceParamType, cli, get_context, json_option, pass_context, Context
@cli.command()
@ -43,7 +43,7 @@ def whois(ctx: Context, account: str, json: bool):
@cli.command()
@click.argument("instance", callback=validate_instance, required=False)
@click.argument("instance", type=InstanceParamType(), callback=validate_instance, required=False)
@json_option
def instance(instance: Optional[str], json: bool):
"""Display instance details

Wyświetl plik

@ -2,7 +2,7 @@ import sys
import click
from toot import api
from toot.cli import cli, get_context, pass_context, Context
from toot.cli import InstanceParamType, cli, get_context, pass_context, Context
from typing import Optional
from toot.cli.validators import validate_instance
@ -13,6 +13,7 @@ from toot.output import print_notifications, print_timeline
@cli.command()
@click.option(
"--instance", "-i",
type=InstanceParamType(),
callback=validate_instance,
help="""Domain or base URL of the instance from which to read,
e.g. 'mastodon.social' or 'https://mastodon.social'""",
@ -110,7 +111,10 @@ def bookmarks(
@cli.command()
@click.option("--clear", help="Dismiss all notifications and exit")
@click.option(
"--clear", is_flag=True,
help="Dismiss all notifications and exit"
)
@click.option(
"--reverse", "-r", is_flag=True,
help="Reverse the order of the shown notifications (newest on top)"

Wyświetl plik

@ -1,8 +1,8 @@
import click
from typing import Optional
from toot.cli import TUI_COLORS, Context, cli, pass_context
from toot.cli.validators import validate_tui_colors
from toot.cli import TUI_COLORS, VISIBILITY_CHOICES, IMAGE_FORMAT_CHOICES, Context, cli, pass_context
from toot.cli.validators import validate_tui_colors, validate_cache_size
from toot.tui.app import TUI, TuiOptions
COLOR_OPTIONS = ", ".join(TUI_COLORS.keys())
@ -24,12 +24,37 @@ COLOR_OPTIONS = ", ".join(TUI_COLORS.keys())
help=f"""Number of colors to use, one of {COLOR_OPTIONS}, defaults to 16 if
using --color, and 1 if using --no-color."""
)
@click.option(
"-s", "--cache-size",
callback=validate_cache_size,
help="""Specify the image cache maximum size in megabytes. Default: 10MB.
Minimum: 1MB."""
)
@click.option(
"-v", "--default-visibility",
type=click.Choice(VISIBILITY_CHOICES),
help="Default visibility when posting new toots; overrides the server-side preference"
)
@click.option(
"-s", "--always-show-sensitive",
is_flag=True,
help="Expand toots with content warnings automatically"
)
@click.option(
"-f", "--image-format",
type=click.Choice(IMAGE_FORMAT_CHOICES),
help="Image output format; support varies across terminals. Default: block"
)
@pass_context
def tui(
ctx: Context,
colors: Optional[int],
media_viewer: Optional[str],
always_show_sensitive: bool,
relative_datetimes: bool,
cache_size: Optional[int],
default_visibility: Optional[str],
image_format: Optional[str]
):
"""Launches the toot terminal user interface"""
if colors is None:
@ -39,6 +64,10 @@ def tui(
colors=colors,
media_viewer=media_viewer,
relative_datetimes=relative_datetimes,
cache_size=cache_size,
default_visibility=default_visibility,
always_show_sensitive=always_show_sensitive,
image_format=image_format,
)
tui = TUI.create(ctx.app, ctx.user, options)
tui.run()

Wyświetl plik

@ -73,3 +73,21 @@ def validate_tui_colors(ctx, param, value) -> Optional[int]:
return TUI_COLORS[value]
raise click.BadParameter(f"Invalid value: {value}. Expected one of: {', '.join(TUI_COLORS)}")
def validate_cache_size(ctx: click.Context, param: str, value: Optional[str]) -> Optional[int]:
"""validates the cache size parameter"""
if value is None:
return 1024 * 1024 * 10 # default 10MB
else:
if value.isdigit():
size = int(value)
else:
raise click.BadParameter("Cache size must be numeric.")
if size > 1024:
raise click.BadParameter("Cache size too large: 1024MB maximum.")
elif size < 1:
raise click.BadParameter("Cache size too small: 1MB minimum.")
return size

Wyświetl plik

@ -17,11 +17,11 @@ def get_config_file_path():
return join(get_config_dir(), TOOT_CONFIG_FILE_NAME)
def user_id(user):
def user_id(user: User):
return "{}@{}".format(user.username, user.instance)
def make_config(path):
def make_config(path: str):
"""Creates an empty toot configuration file."""
config = {
"apps": {},
@ -58,7 +58,7 @@ def save_config(config):
return json.dump(config, f, indent=True, sort_keys=True)
def extract_user_app(config, user_id):
def extract_user_app(config, user_id: str):
if user_id not in config['users']:
return None, None
@ -82,7 +82,7 @@ def get_active_user_app():
return None, None
def get_user_app(user_id):
def get_user_app(user_id: str):
"""Returns (User, App) for given user ID or (None, None) if user is not logged in."""
return extract_user_app(load_config(), user_id)
@ -93,7 +93,7 @@ def load_app(instance: str) -> Optional[App]:
return App(**config['apps'][instance])
def load_user(user_id, throw=False):
def load_user(user_id: str, throw=False):
config = load_config()
if user_id in config['users']:
@ -120,7 +120,7 @@ def save_app(app: App):
config['apps'][app.instance] = app._asdict()
def delete_app(config, app):
def delete_app(config, app: App):
with edit_config() as config:
config['apps'].pop(app.instance, None)

Wyświetl plik

@ -9,17 +9,23 @@ different versions of the Mastodon API.
"""
import dataclasses
import typing as t
from dataclasses import dataclass, is_dataclass
from datetime import date, datetime
from functools import lru_cache
from typing import Any, Dict, List, Optional, Tuple, Type, TypeVar, Union
from typing import get_type_hints
from typing import Any, Dict, NamedTuple, Optional, Type, TypeVar, Union
from typing import get_args, get_origin, get_type_hints
from toot.typing_compat import get_args, get_origin
from toot.utils import get_text
from toot.utils.datetime import parse_datetime
# Generic data class instance
T = TypeVar("T")
# A dict decoded from JSON
Data = Dict[str, Any]
@dataclass
class AccountField:
@ -59,8 +65,8 @@ class Account:
header: str
header_static: str
locked: bool
fields: List[AccountField]
emojis: List[CustomEmoji]
fields: t.List[AccountField]
emojis: t.List[CustomEmoji]
bot: bool
group: bool
discoverable: Optional[bool]
@ -76,7 +82,7 @@ class Account:
source: Optional[dict]
@staticmethod
def __toot_prepare__(obj: Dict) -> Dict:
def __toot_prepare__(obj: Data) -> Data:
# Pleroma has not yet converted last_status_at from datetime to date
# so trim it here so it doesn't break when converting to date.
# See: https://git.pleroma.social/pleroma/pleroma/-/issues/1470
@ -154,10 +160,10 @@ class Poll:
multiple: bool
votes_count: int
voters_count: Optional[int]
options: List[PollOption]
emojis: List[CustomEmoji]
options: t.List[PollOption]
emojis: t.List[CustomEmoji]
voted: Optional[bool]
own_votes: Optional[List[int]]
own_votes: Optional[t.List[int]]
@dataclass
@ -207,11 +213,11 @@ class Filter:
"""
id: str
title: str
context: List[str]
context: t.List[str]
expires_at: Optional[datetime]
filter_action: str
keywords: List[FilterKeyword]
statuses: List[FilterStatus]
keywords: t.List[FilterKeyword]
statuses: t.List[FilterStatus]
@dataclass
@ -220,7 +226,7 @@ class FilterResult:
https://docs.joinmastodon.org/entities/FilterResult/
"""
filter: Filter
keyword_matches: Optional[List[str]]
keyword_matches: Optional[t.List[str]]
status_matches: Optional[str]
@ -237,11 +243,11 @@ class Status:
visibility: str
sensitive: bool
spoiler_text: str
media_attachments: List[MediaAttachment]
media_attachments: t.List[MediaAttachment]
application: Optional[Application]
mentions: List[StatusMention]
tags: List[StatusTag]
emojis: List[CustomEmoji]
mentions: t.List[StatusMention]
tags: t.List[StatusTag]
emojis: t.List[CustomEmoji]
reblogs_count: int
favourites_count: int
replies_count: int
@ -259,14 +265,14 @@ class Status:
muted: Optional[bool]
bookmarked: Optional[bool]
pinned: Optional[bool]
filtered: Optional[List[FilterResult]]
filtered: Optional[t.List[FilterResult]]
@property
def original(self) -> "Status":
return self.reblog or self
@staticmethod
def __toot_prepare__(obj: Dict) -> Dict:
def __toot_prepare__(obj: Data) -> Data:
# Pleroma has a bug where created_at is set to an empty string.
# To avoid marking created_at as optional, which would require work
# because we count on it always existing, set it to current datetime.
@ -289,8 +295,8 @@ class Report:
comment: str
forwarded: bool
created_at: datetime
status_ids: Optional[List[str]]
rule_ids: Optional[List[str]]
status_ids: Optional[t.List[str]]
rule_ids: Optional[t.List[str]]
target_account: Account
@ -328,7 +334,7 @@ class InstanceConfigurationStatuses:
@dataclass
class InstanceConfigurationMediaAttachments:
supported_mime_types: List[str]
supported_mime_types: t.List[str]
image_size_limit: int
image_matrix_limit: int
video_size_limit: int
@ -377,13 +383,13 @@ class Instance:
urls: InstanceUrls
stats: InstanceStats
thumbnail: Optional[str]
languages: List[str]
languages: t.List[str]
registrations: bool
approval_required: bool
invites_enabled: bool
configuration: InstanceConfiguration
contact_account: Optional[Account]
rules: List[Rule]
rules: t.List[Rule]
@dataclass
@ -397,7 +403,7 @@ class Relationship:
following: bool
showing_reblogs: bool
notifying: bool
languages: List[str]
languages: t.List[str]
followed_by: bool
blocking: bool
blocked_by: bool
@ -428,7 +434,7 @@ class Tag:
"""
name: str
url: str
history: List[TagHistory]
history: t.List[TagHistory]
following: Optional[bool]
@ -445,26 +451,37 @@ class FeaturedTag:
last_status_at: datetime
# Generic data class instance
T = TypeVar("T")
@dataclass
class List:
"""
Represents a list of some users that the authenticated user follows.
https://docs.joinmastodon.org/entities/List/
"""
id: str
title: str
# This is a required field on Mastodon, but not supported on Pleroma/Akkoma
# see: https://git.pleroma.social/pleroma/pleroma/-/issues/2918
replies_policy: Optional[str]
# ------------------------------------------------------------------------------
class Field(NamedTuple):
name: str
type: Any
default: Any
class ConversionError(Exception):
"""Raised when conversion fails from JSON value to data class field."""
def __init__(
self,
data_class: Type,
field_name: str,
field_type: Type,
field_value: Optional[str]
):
def __init__(self, data_class: type, field: Field, field_value: Optional[str]):
super().__init__(
f"Failed converting field `{data_class.__name__}.{field_name}` "
+ f"of type `{field_type.__name__}` from value {field_value!r}"
f"Failed converting field `{data_class.__name__}.{field.name}` "
+ f"of type `{field.type.__name__}` from value {field_value!r}"
)
def from_dict(cls: Type[T], data: Dict) -> T:
def from_dict(cls: Type[T], data: Data) -> T:
"""Convert a nested dict into an instance of `cls`."""
# Apply __toot_prepare__ if it exists
prepare = getattr(cls, '__toot_prepare__', None)
@ -472,19 +489,19 @@ def from_dict(cls: Type[T], data: Dict) -> T:
data = prepare(data)
def _fields():
for name, type, default in get_fields(cls):
value = data.get(name, default)
converted = _convert_with_error_handling(cls, name, type, value)
yield name, converted
for field in _get_fields(cls):
value = data.get(field.name, field.default)
converted = _convert_with_error_handling(cls, field, value)
yield field.name, converted
return cls(**dict(_fields()))
@lru_cache(maxsize=100)
def get_fields(cls: Type) -> List[Tuple[str, Type, Any]]:
@lru_cache
def _get_fields(cls: type) -> t.List[Field]:
hints = get_type_hints(cls)
return [
(
Field(
field.name,
_prune_optional(hints[field.name]),
_get_default_value(field)
@ -493,11 +510,11 @@ def get_fields(cls: Type) -> List[Tuple[str, Type, Any]]:
]
def from_dict_list(cls: Type[T], data: List[Dict]) -> List[T]:
def from_dict_list(cls: Type[T], data: t.List[Data]) -> t.List[T]:
return [from_dict(cls, x) for x in data]
def _get_default_value(field):
def _get_default_value(field: dataclasses.Field):
if field.default is not dataclasses.MISSING:
return field.default
@ -507,21 +524,16 @@ def _get_default_value(field):
return None
def _convert_with_error_handling(
data_class: Type,
field_name: str,
field_type: Type,
field_value: Optional[str]
):
def _convert_with_error_handling(data_class: type, field: Field, field_value: Any) -> Any:
try:
return _convert(field_type, field_value)
return _convert(field.type, field_value)
except ConversionError:
raise
except Exception:
raise ConversionError(data_class, field_name, field_type, field_value)
raise ConversionError(data_class, field, field_value)
def _convert(field_type, value):
def _convert(field_type: Any, value: Any) -> Any:
if value is None:
return None
@ -544,7 +556,7 @@ def _convert(field_type, value):
raise ValueError(f"Not implemented for type '{field_type}'")
def _prune_optional(field_type: Type) -> Type:
def _prune_optional(field_type: type) -> type:
"""For `Optional[<type>]` returns the encapsulated `<type>`."""
if get_origin(field_type) == Union:
args = get_args(field_type)

Wyświetl plik

@ -38,7 +38,7 @@ def _get_error_message(response):
except Exception:
pass
return "Unknown error"
return f"Unknown error: {response.status_code} {response.reason}"
def process_response(response):
@ -81,6 +81,22 @@ def post(app, user, path, headers=None, files=None, data=None, json=None, allow_
return anon_post(url, headers=headers, files=files, data=data, json=json, allow_redirects=allow_redirects)
def anon_put(url, headers=None, files=None, data=None, json=None, allow_redirects=True):
request = Request(method="PUT", url=url, headers=headers, files=files, data=data, json=json)
response = send_request(request, allow_redirects)
return process_response(response)
def put(app, user, path, headers=None, files=None, data=None, json=None, allow_redirects=True):
url = app.base_url + path
headers = headers or {}
headers["Authorization"] = f"Bearer {user.access_token}"
return anon_put(url, headers=headers, files=files, data=data, json=json, allow_redirects=allow_redirects)
def patch(app, user, path, headers=None, files=None, data=None, json=None):
url = app.base_url + path

Wyświetl plik

@ -1,12 +1,12 @@
import click
import re
import textwrap
import shutil
import textwrap
import typing as t
from toot.entities import Account, Instance, Notification, Poll, Status
from toot.entities import Account, Instance, Notification, Poll, Status, List
from toot.utils import get_text, html_to_paragraphs
from toot.wcstring import wc_wrap
from typing import Any, Generator, Iterable, List
from wcwidth import wcswidth
@ -38,7 +38,7 @@ def instance_to_text(instance: Instance, width: int) -> str:
return "\n".join(instance_lines(instance, width))
def instance_lines(instance: Instance, width: int) -> Generator[str, None, None]:
def instance_lines(instance: Instance, width: int) -> t.Generator[str, None, None]:
yield f"{green(instance.title)}"
yield f"{blue(instance.uri)}"
yield f"running Mastodon {instance.version}"
@ -78,7 +78,7 @@ def account_to_text(account: Account, width: int) -> str:
return "\n".join(account_lines(account, width))
def account_lines(account: Account, width: int) -> Generator[str, None, None]:
def account_lines(account: Account, width: int) -> t.Generator[str, None, None]:
acct = f"@{account.acct}"
since = account.created_at.strftime("%Y-%m-%d")
@ -119,13 +119,13 @@ def print_tag_list(tags):
click.echo(f"* {format_tag_name(tag)}\t{tag['url']}")
def print_lists(lists):
def print_lists(lists: t.List[List]):
headers = ["ID", "Title", "Replies"]
data = [[lst["id"], lst["title"], lst["replies_policy"]] for lst in lists]
data = [[lst.id, lst.title, lst.replies_policy or ""] for lst in lists]
print_table(headers, data)
def print_table(headers: List[str], data: List[List[str]]):
def print_table(headers: t.List[str], data: t.List[t.List[str]]):
widths = [[len(cell) for cell in row] for row in data + [headers]]
widths = [max(width) for width in zip(*widths)]
@ -178,7 +178,7 @@ def status_to_text(status: Status, width: int) -> str:
return "\n".join(status_lines(status))
def status_lines(status: Status) -> Generator[str, None, None]:
def status_lines(status: Status) -> t.Generator[str, None, None]:
width = get_width()
status_id = status.id
in_reply_to_id = status.in_reply_to_id
@ -219,10 +219,10 @@ def status_lines(status: Status) -> Generator[str, None, None]:
reply = f"↲ In reply to {yellow(in_reply_to_id)} " if in_reply_to_id else ""
boost = f"{blue(reblogged_by_acct)} boosted " if reblogged_by else ""
yield f"ID {yellow(status_id)} {reply} {boost}"
yield f"ID {yellow(status_id)} Visibility: {status.visibility} {reply} {boost}"
def html_lines(html: str, width: int) -> Generator[str, None, None]:
def html_lines(html: str, width: int) -> t.Generator[str, None, None]:
first = True
for paragraph in html_to_paragraphs(html):
if not first:
@ -233,7 +233,7 @@ def html_lines(html: str, width: int) -> Generator[str, None, None]:
first = False
def poll_lines(poll: Poll) -> Generator[str, None, None]:
def poll_lines(poll: Poll) -> t.Generator[str, None, None]:
for idx, option in enumerate(poll.options):
perc = (round(100 * option.votes_count / poll.votes_count)
if poll.votes_count and option.votes_count is not None else 0)
@ -258,7 +258,7 @@ def poll_lines(poll: Poll) -> Generator[str, None, None]:
yield poll_footer
def print_timeline(items: Iterable[Status]):
def print_timeline(items: t.Iterable[Status]):
print_divider()
for item in items:
print_status(item)
@ -272,7 +272,7 @@ def print_notification(notification: Notification):
print_status(notification.status)
def print_notifications(notifications: List[Notification]):
def print_notifications(notifications: t.List[Notification]):
for notification in notifications:
if notification.type not in ['pleroma:emoji_reaction']:
print_divider()
@ -316,25 +316,25 @@ def format_account_name(account: Account) -> str:
# Shorthand functions for coloring output
def blue(text: Any) -> str:
def blue(text: t.Any) -> str:
return click.style(text, fg="blue")
def bold(text: Any) -> str:
def bold(text: t.Any) -> str:
return click.style(text, bold=True)
def cyan(text: Any) -> str:
def cyan(text: t.Any) -> str:
return click.style(text, fg="cyan")
def dim(text: Any) -> str:
def dim(text: t.Any) -> str:
return click.style(text, dim=True)
def green(text: Any) -> str:
def green(text: t.Any) -> str:
return click.style(text, fg="green")
def yellow(text: Any) -> str:
def yellow(text: t.Any) -> str:
return click.style(text, fg="yellow")

Wyświetl plik

@ -2,22 +2,27 @@ import logging
import subprocess
import urwid
from concurrent.futures import ThreadPoolExecutor
from typing import NamedTuple, Optional
from datetime import datetime, timezone
from toot import api, config, __version__, settings
from toot import App, User
from toot.cli import get_default_visibility
from toot.exceptions import ApiError
from toot.utils.datetime import parse_datetime
from .compose import StatusComposer
from .constants import PALETTE
from .entities import Status
from .images import TuiScreen, load_image
from .overlays import ExceptionStackTrace, GotoMenu, Help, StatusSource, StatusLinks, StatusZoom
from .overlays import StatusDeleteConfirmation, Account
from .poll import Poll
from .timeline import Timeline
from .utils import get_max_toot_chars, parse_content_links, copy_to_clipboard
from .utils import get_max_toot_chars, parse_content_links, copy_to_clipboard, LRUCache
from .widgets import ModalBox, RoundedLineBox
logger = logging.getLogger(__name__)
@ -30,7 +35,11 @@ DEFAULT_MAX_TOOT_CHARS = 500
class TuiOptions(NamedTuple):
colors: int
media_viewer: Optional[str]
always_show_sensitive: bool
relative_datetimes: bool
cache_size: int
default_visibility: Optional[str]
image_format: Optional[str]
class Header(urwid.WidgetWrap):
@ -90,7 +99,7 @@ class TUI(urwid.Frame):
@staticmethod
def create(app: App, user: User, args: TuiOptions):
"""Factory method, sets up TUI and an event loop."""
screen = urwid.raw_display.Screen()
screen = TuiScreen()
screen.set_terminal_properties(args.colors)
tui = TUI(app, user, screen, args)
@ -137,11 +146,18 @@ class TUI(urwid.Frame):
self.can_translate = False
self.account = None
self.followed_accounts = []
self.preferences = {}
if self.options.cache_size:
self.cache_max = 1024 * 1024 * self.options.cache_size
else:
self.cache_max = 1024 * 1024 * 10 # default 10MB
super().__init__(self.body, header=self.header, footer=self.footer)
def run(self):
self.loop.set_alarm_in(0, lambda *args: self.async_load_instance())
self.loop.set_alarm_in(0, lambda *args: self.async_load_preferences())
self.loop.set_alarm_in(0, lambda *args: self.async_load_timeline(
is_initial=True, timeline_name="home"))
self.loop.set_alarm_in(0, lambda *args: self.async_load_followed_accounts())
@ -320,12 +336,27 @@ class TUI(urwid.Frame):
# get the major version number of the server
# this works for Mastodon and Pleroma version strings
# Mastodon versions < 4 do not have translation service
# If the version is missing, assume 0 as a fallback
# Revisit this logic if Pleroma implements translation
ch = instance["version"][0]
version = instance["version"]
ch = "0" if not version else version[0]
self.can_translate = int(ch) > 3 if ch.isnumeric() else False
return self.run_in_thread(_load_instance, done_callback=_done)
def async_load_preferences(self):
"""
Attempt to update user preferences from instance.
https://docs.joinmastodon.org/methods/preferences/
"""
def _load_preferences():
return api.get_preferences(self.app, self.user).json()
def _done(preferences):
self.preferences = preferences
return self.run_in_thread(_load_preferences, done_callback=_done)
def async_load_followed_accounts(self):
def _load_accounts():
try:
@ -400,11 +431,45 @@ class TUI(urwid.Frame):
def _post(timeline, *args):
self.post_status(*args)
composer = StatusComposer(self.max_toot_chars, self.user.username, in_reply_to)
# If the user specified --default-visibility, use that; otherwise,
# try to use the server-side default visibility. If that fails, fall
# back to get_default_visibility().
visibility = (self.options.default_visibility or
self.preferences.get('posting:default:visibility',
get_default_visibility()))
composer = StatusComposer(self.max_toot_chars, self.user.username,
visibility, in_reply_to)
urwid.connect_signal(composer, "close", _close)
urwid.connect_signal(composer, "post", _post)
self.open_overlay(composer, title="Compose status")
def async_edit(self, status):
def _fetch_source():
return api.fetch_status_source(self.app, self.user, status.id).json()
def _done(source):
self.close_overlay()
self.show_edit(status, source)
please_wait = ModalBox("Loading status...")
self.open_overlay(please_wait)
self.run_in_thread(_fetch_source, done_callback=_done)
def show_edit(self, status, source):
def _close(*args):
self.close_overlay()
def _edit(timeline, *args):
self.edit_status(status, *args)
composer = StatusComposer(self.max_toot_chars, self.user.username,
visibility=None, edit=status, source=source)
urwid.connect_signal(composer, "close", _close)
urwid.connect_signal(composer, "post", _edit)
self.open_overlay(composer, title="Edit status")
def show_goto_menu(self):
user_timelines = self.config.get("timelines", {})
user_lists = api.get_lists(self.app, self.user) or []
@ -552,11 +617,47 @@ class TUI(urwid.Frame):
self.footer.set_message("Status posted {} \\o/".format(status.id))
self.close_overlay()
def edit_status(self, status, content, warning, visibility, in_reply_to_id):
# We don't support editing polls (yet), so to avoid losing the poll
# data from the original toot, copy it to the edit request.
poll_args = {}
poll = status.original.data.get('poll', None)
if poll is not None:
poll_args['poll_options'] = [o['title'] for o in poll['options']]
poll_args['poll_multiple'] = poll['multiple']
# Convert absolute expiry time into seconds from now.
expires_at = parse_datetime(poll['expires_at'])
expires_in = int((expires_at - datetime.now(timezone.utc)).total_seconds())
poll_args['poll_expires_in'] = expires_in
if 'hide_totals' in poll:
poll_args['poll_hide_totals'] = poll['hide_totals']
data = api.edit_status(
self.app,
self.user,
status.id,
content,
spoiler_text=warning,
visibility=visibility,
**poll_args
).json()
new_status = self.make_status(data)
self.footer.set_message("Status edited {} \\o/".format(status.id))
self.close_overlay()
if self.timeline is not None:
self.timeline.update_status(new_status)
def show_account(self, account_id):
account = api.whois(self.app, self.user, account_id)
relationship = api.get_relationship(self.app, self.user, account_id)
self.open_overlay(
widget=Account(self.app, self.user, account, relationship),
widget=Account(self.app, self.user, account, relationship, self.options),
title="Account",
)
@ -665,6 +766,27 @@ class TUI(urwid.Frame):
return self.run_in_thread(_delete, done_callback=_done)
def async_load_image(self, timeline, status, path, placeholder_index):
def _load():
# don't bother loading images for statuses we are not viewing now
if timeline.get_focused_status().id != status.id:
return
if not hasattr(timeline, "images"):
timeline.images = LRUCache(cache_max_bytes=self.cache_max)
img = load_image(path)
if img:
timeline.images[str(hash(path))] = img
def _done(loop):
# don't bother loading images for statuses we are not viewing now
if timeline.get_focused_status().id != status.id:
return
timeline.update_status_image(status, path, placeholder_index)
return self.run_in_thread(_load, done_callback=_done)
def copy_status(self, status):
# TODO: copy a better version of status content
# including URLs
@ -679,7 +801,7 @@ class TUI(urwid.Frame):
)
def open_overlay(self, widget, options={}, title=""):
top_widget = urwid.LineBox(widget, title=title)
top_widget = RoundedLineBox(widget, title=title)
bottom_widget = self.body
_options = self.default_overlay_options.copy()

Wyświetl plik

@ -1,8 +1,6 @@
import urwid
import logging
from toot.cli import get_default_visibility
from .constants import VISIBILITY_OPTIONS
from .widgets import Button, EditBox
@ -11,21 +9,22 @@ logger = logging.getLogger(__name__)
class StatusComposer(urwid.Frame):
"""
UI for compose and posting a status message.
UI for composing or editing a status message.
To edit a status, provide the original status in 'edit', and optionally
provide the status source (from the /status/:id/source API endpoint) in
'source'; this should have at least a 'text' member, and optionally
'spoiler_text'. If source is not provided, the formatted HTML will be
presented to the user for editing.
"""
signals = ["close", "post"]
def __init__(self, max_chars, username, in_reply_to=None):
def __init__(self, max_chars, username, visibility, in_reply_to=None,
edit=None, source=None):
self.in_reply_to = in_reply_to
self.max_chars = max_chars
self.username = username
text = self.get_initial_text(in_reply_to)
self.content_edit = EditBox(
edit_text=text, edit_pos=len(text), multiline=True, allow_tab=True)
urwid.connect_signal(self.content_edit.edit, "change", self.text_changed)
self.char_count = urwid.Text(["0/{}".format(max_chars)])
self.edit = edit
self.cw_edit = None
self.cw_add_button = Button("Add content warning",
@ -33,13 +32,34 @@ class StatusComposer(urwid.Frame):
self.cw_remove_button = Button("Remove content warning",
on_press=self.remove_content_warning)
self.visibility = (
in_reply_to.visibility if in_reply_to else get_default_visibility()
)
if edit:
if source is None:
text = edit.data["content"]
else:
text = source.get("text", edit.data["content"])
if 'spoiler_text' in source:
self.cw_edit = EditBox(multiline=True, allow_tab=True,
edit_text=source['spoiler_text'])
self.visibility = edit.data["visibility"]
else: # not edit
text = self.get_initial_text(in_reply_to)
self.visibility = (
in_reply_to.visibility if in_reply_to else visibility
)
self.content_edit = EditBox(
edit_text=text, edit_pos=len(text), multiline=True, allow_tab=True)
urwid.connect_signal(self.content_edit.edit, "change", self.text_changed)
self.char_count = urwid.Text(["0/{}".format(max_chars)])
self.visibility_button = Button("Visibility: {}".format(self.visibility),
on_press=self.choose_visibility)
self.post_button = Button("Post", on_press=self.post)
self.post_button = Button("Edit" if edit else "Post", on_press=self.post)
self.cancel_button = Button("Cancel", on_press=self.close)
contents = list(self.generate_list_items())

Wyświetl plik

@ -46,7 +46,7 @@ PALETTE = [
('shortcut_highlight', 'white,bold', '', 'bold'),
('warning', 'light red', ''),
# Visiblity
# Visibility
('visibility_public', 'dark gray', ''),
('visibility_unlisted', 'white', ''),
('visibility_private', 'dark cyan', ''),

Wyświetl plik

@ -53,6 +53,10 @@ class Status:
self.id = self.data["id"]
self.account = self._get_account()
self.created_at = parse_datetime(data["created_at"])
if data.get("edited_at"):
self.edited_at = parse_datetime(data["edited_at"])
else:
self.edited_at = None
self.author = self._get_author()
self.favourited = data.get("favourited", False)
self.reblogged = data.get("reblogged", False)

104
toot/tui/images.py 100644
Wyświetl plik

@ -0,0 +1,104 @@
import urwid
import math
import requests
import warnings
# If term_image is loaded use their screen implementation which handles images
try:
from term_image.widget import UrwidImageScreen, UrwidImage
from term_image.image import BaseImage, KittyImage, ITerm2Image, BlockImage
from term_image import disable_queries # prevent phantom keystrokes
from PIL import Image, ImageDraw
TuiScreen = UrwidImageScreen
disable_queries()
def image_support_enabled():
return True
def can_render_pixels(image_format):
return image_format in ['kitty', 'iterm']
def get_base_image(image, image_format) -> BaseImage:
# we don't autodetect kitty, iterm; we choose based on option switches
BaseImage.forced_support = True
if image_format == 'kitty':
return KittyImage(image)
elif image_format == 'iterm':
return ITerm2Image(image)
else:
return BlockImage(image)
def resize_image(basewidth: int, baseheight: int, img: Image.Image) -> Image.Image:
if baseheight and not basewidth:
hpercent = baseheight / float(img.size[1])
width = math.ceil(img.size[0] * hpercent)
img = img.resize((width, baseheight), Image.Resampling.LANCZOS)
elif basewidth and not baseheight:
wpercent = (basewidth / float(img.size[0]))
hsize = int((float(img.size[1]) * float(wpercent)))
img = img.resize((basewidth, hsize), Image.Resampling.LANCZOS)
else:
img = img.resize((basewidth, baseheight), Image.Resampling.LANCZOS)
if img.mode != 'P':
img = img.convert('RGB')
return img
def add_corners(img, rad):
circle = Image.new('L', (rad * 2, rad * 2), 0)
draw = ImageDraw.Draw(circle)
draw.ellipse((0, 0, rad * 2, rad * 2), fill=255)
alpha = Image.new('L', img.size, "white")
w, h = img.size
alpha.paste(circle.crop((0, 0, rad, rad)), (0, 0))
alpha.paste(circle.crop((0, rad, rad, rad * 2)), (0, h - rad))
alpha.paste(circle.crop((rad, 0, rad * 2, rad)), (w - rad, 0))
alpha.paste(circle.crop((rad, rad, rad * 2, rad * 2)), (w - rad, h - rad))
img.putalpha(alpha)
return img
def load_image(url):
with warnings.catch_warnings():
warnings.simplefilter("ignore") # suppress "corrupt exif" output from PIL
try:
img = Image.open(requests.get(url, stream=True).raw)
if img.format == 'PNG' and img.mode != 'RGBA':
img = img.convert("RGBA")
return img
except Exception:
return None
def graphics_widget(img, image_format="block", corner_radius=0) -> urwid.Widget:
if not img:
return urwid.SolidFill(fill_char=" ")
if can_render_pixels(image_format) and corner_radius > 0:
render_img = add_corners(img, 10)
else:
render_img = img
return UrwidImage(get_base_image(render_img, image_format), '<', upscale=True)
# "<" means left-justify the image
except ImportError:
from urwid.raw_display import Screen
TuiScreen = Screen
def image_support_enabled():
return False
def can_render_pixels(image_format: str):
return False
def get_base_image(image, image_format: str):
return None
def add_corners(img, rad):
return None
def load_image(url):
return None
def graphics_widget(img, image_format="block", corner_radius=0) -> urwid.Widget:
return urwid.SolidFill(fill_char=" ")

Wyświetl plik

@ -5,7 +5,9 @@ import webbrowser
from toot import __version__
from toot import api
from toot.tui.utils import highlight_keys
from toot.tui.images import image_support_enabled, load_image, graphics_widget
from toot.tui.widgets import Button, EditBox, SelectableText
from toot.tui.richtext import html_to_widgets
@ -226,6 +228,7 @@ class Help(urwid.Padding):
yield urwid.Text(h(" [N] - Translate status if possible (toggle)"))
yield urwid.Text(h(" [R] - Reply to current status"))
yield urwid.Text(h(" [S] - Show text marked as sensitive"))
yield urwid.Text(h(" [M] - Show status media"))
yield urwid.Text(h(" [T] - Show status thread (replies)"))
yield urwid.Text(h(" [L] - Show the status links"))
yield urwid.Text(h(" [U] - Show the status data in JSON as received from the server"))
@ -241,11 +244,12 @@ class Help(urwid.Padding):
class Account(urwid.ListBox):
"""Shows account data and provides various actions"""
def __init__(self, app, user, account, relationship):
def __init__(self, app, user, account, relationship, options):
self.app = app
self.user = user
self.account = account
self.relationship = relationship
self.options = options
self.last_action = None
self.setup_listbox()
@ -254,6 +258,30 @@ class Account(urwid.ListBox):
walker = urwid.SimpleListWalker(actions)
super().__init__(walker)
def account_header(self, account):
if image_support_enabled() and account['avatar'] and not account["avatar"].endswith("missing.png"):
img = load_image(account['avatar'])
aimg = urwid.BoxAdapter(
graphics_widget(img, image_format=self.options.image_format, corner_radius=10), 10)
else:
aimg = urwid.BoxAdapter(urwid.SolidFill(" "), 10)
if image_support_enabled() and account['header'] and not account["header"].endswith("missing.png"):
img = load_image(account['header'])
himg = (urwid.BoxAdapter(
graphics_widget(img, image_format=self.options.image_format, corner_radius=10), 10))
else:
himg = urwid.BoxAdapter(urwid.SolidFill(" "), 10)
atxt = urwid.Pile([urwid.Divider(),
(urwid.Text(("account", account["display_name"]))),
(urwid.Text(("highlight", "@" + self.account['acct'])))])
columns = urwid.Columns([aimg, ("weight", 9999, himg)], dividechars=2, min_width=20)
header = urwid.Pile([columns, urwid.Divider(), atxt])
return header
def generate_contents(self, account, relationship=None, last_action=None):
if self.last_action and not self.last_action.startswith("Confirm"):
yield Button(f"Confirm {self.last_action}", on_press=take_action, user_data=self)
@ -275,11 +303,11 @@ class Account(urwid.ListBox):
yield urwid.Divider("")
yield urwid.Divider()
yield urwid.Text([("account", f"@{account['acct']}"), f" {account['display_name']}"])
yield self.account_header(account)
if account["note"]:
yield urwid.Divider()
widgetlist = html_to_widgets(account["note"])
for line in widgetlist:
yield (line)

Wyświetl plik

@ -3,7 +3,7 @@ import urwid
from toot import api
from toot.exceptions import ApiError
from toot.utils.datetime import parse_datetime
from .widgets import Button, CheckBox, RadioButton
from .widgets import Button, CheckBox, RadioButton, RoundedLineBox
from .richtext import html_to_widgets
@ -27,7 +27,7 @@ class Poll(urwid.ListBox):
def build_linebox(self, contents):
contents = urwid.Pile(list(contents))
contents = urwid.Padding(contents, left=1, right=1)
return urwid.LineBox(contents)
return RoundedLineBox(contents)
def vote(self, button_widget):
poll = self.status.original.data.get("poll")

Wyświetl plik

@ -60,7 +60,10 @@ def html_to_widgets(html, recovery_attempt=False) -> List[urwid.Widget]:
def url_to_widget(url: str):
widget = len(url), urwid.Filler(Hyperlink(url, "link", url))
try:
widget = len(url), urwid.Filler(Hyperlink(url, "link", url))
except ValueError:
widget = len(url), urwid.Filler(urwid.Text(url)) # don't style as link
return TextEmbed(widget)
@ -98,10 +101,16 @@ def text_to_widget(attr, markup) -> urwid.Widget:
if match:
label, url = match.groups()
anchor_attr = get_best_anchor_attr(attr_list)
markup_list.append((
len(label),
urwid.Filler(Hyperlink(url, anchor_attr, label)),
))
try:
markup_list.append((
len(label),
urwid.Filler(Hyperlink(url, anchor_attr, label)),
))
except ValueError:
markup_list.append((
len(label),
urwid.Filler(urwid.Text(url)), # don't style as link
))
else:
markup_list.append(run)
else:

Wyświetl plik

@ -1,8 +1,3 @@
# scroll.py
#
# Copied from the stig project by rndusr@github
# https://github.com/rndusr/stig
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
@ -36,12 +31,12 @@ class Scrollable(urwid.WidgetDecoration):
def selectable(self):
return True
def __init__(self, widget):
def __init__(self, widget, force_forward_keypress = False):
"""Box widget that makes a fixed or flow widget vertically scrollable
TODO: Focusable widgets are handled, including switching focus, but
possibly not intuitively, depending on the arrangement of widgets. When
switching focus to a widget that is outside of the visible part of the
switching focus to a widget that is ouside of the visible part of the
original widget, the canvas scrolls up/down to the focused widget. It
would be better to scroll until the next focusable widget is in sight
first. But for that to work we must somehow obtain a list of focusable
@ -54,6 +49,7 @@ class Scrollable(urwid.WidgetDecoration):
self._forward_keypress = None
self._old_cursor_coords = None
self._rows_max_cached = 0
self.force_forward_keypress = force_forward_keypress
self.__super.__init__(widget)
def render(self, size, focus=False):
@ -111,6 +107,51 @@ class Scrollable(urwid.WidgetDecoration):
if canv_full.cursor is not None:
# Full canvas contains the cursor, but scrolled out of view
self._forward_keypress = False
# Reset cursor position on page/up down scrolling
try:
if hasattr(ow, "automove_cursor_on_scroll") and ow.automove_cursor_on_scroll:
pwi = 0
ch = 0
last_hidden = False
first_visible = False
for w,o in ow.contents:
wcanv = w.render((maxcol,))
wh = wcanv.rows()
if wh:
ch += wh
if not last_hidden and ch >= self._trim_top:
last_hidden = True
elif last_hidden:
if not first_visible:
first_visible = True
if w.selectable():
ow.focus_item = pwi
st = None
nf = ow.get_focus()
if hasattr(nf, "key_timeout"):
st = nf
elif hasattr(nf, "original_widget"):
no = nf.original_widget
if hasattr(no, "original_widget"):
st = no.original_widget
else:
if hasattr(no, "key_timeout"):
st = no
if st and hasattr(st, "key_timeout") and hasattr(st, "keypress") and callable(st.keypress):
st.keypress(None, None)
break
pwi += 1
except Exception as e:
pass
else:
# Original widget does not have a cursor, but may be selectable
@ -132,7 +173,7 @@ class Scrollable(urwid.WidgetDecoration):
def keypress(self, size, key):
# Maybe offer key to original widget
if self._forward_keypress:
if self._forward_keypress or self.force_forward_keypress:
ow = self._original_widget
ow_size = self._get_original_widget_size(size)
@ -216,7 +257,7 @@ class Scrollable(urwid.WidgetDecoration):
# If the cursor was moved by the most recent keypress, adjust trim_top
# so that the new cursor position is within the displayed canvas part.
# But don't do this if the cursor is at the top/bottom edge so we can still scroll out
if self._old_cursor_coords is not None and self._old_cursor_coords != canv.cursor:
if self._old_cursor_coords is not None and self._old_cursor_coords != canv.cursor and canv.cursor != None:
self._old_cursor_coords = None
curscol, cursrow = canv.cursor
if cursrow < self._trim_top:
@ -227,10 +268,10 @@ class Scrollable(urwid.WidgetDecoration):
def _get_original_widget_size(self, size):
ow = self._original_widget
sizing = ow.sizing()
if FIXED in sizing:
return ()
elif FLOW in sizing:
if FLOW in sizing:
return (size[0],)
elif FIXED in sizing:
return ()
def get_scrollpos(self, size=None, focus=False):
"""Current scrolling position
@ -416,7 +457,10 @@ class ScrollBar(urwid.WidgetDecoration):
if not handled and hasattr(ow, 'set_scrollpos'):
if button == 4: # scroll wheel up
pos = ow.get_scrollpos(ow_size)
ow.set_scrollpos(pos - 1)
newpos = pos - 1
if newpos < 0:
newpos = 0
ow.set_scrollpos(newpos)
return True
elif button == 5: # scroll wheel down
pos = ow.get_scrollpos(ow_size)

Wyświetl plik

@ -1,26 +1,33 @@
import logging
import math
import urwid
import webbrowser
from typing import List, Optional
from toot.tui import app
from toot.tui.richtext import html_to_widgets, url_to_widget
from toot.utils.datetime import parse_datetime, time_ago
from toot.utils.language import language_name
from toot.entities import Status
from toot.tui.scroll import Scrollable, ScrollBar
from toot.tui.utils import highlight_keys
from toot.tui.widgets import SelectableText, SelectableColumns
from toot.tui.images import image_support_enabled, graphics_widget, can_render_pixels
from toot.tui.widgets import SelectableText, SelectableColumns, RoundedLineBox
logger = logging.getLogger("toot")
screen = urwid.raw_display.Screen()
class Timeline(urwid.Columns):
"""
Displays a list of statuses to the left, and status details on the right.
"""
signals = [
"close", # Close thread
"focus", # Focus changed
@ -41,6 +48,7 @@ class Timeline(urwid.Columns):
self.is_thread = is_thread
self.statuses = statuses
self.status_list = self.build_status_list(statuses, focus=focus)
self.can_render_pixels = can_render_pixels(self.tui.options.image_format)
try:
focused_status = statuses[focus]
@ -85,7 +93,7 @@ class Timeline(urwid.Columns):
return urwid.AttrMap(item, None, focus_map={
"status_list_account": "status_list_selected",
"status_list_timestamp": "status_list_selected",
"highligh": "status_list_selected",
"highlight": "status_list_selected",
"dim": "status_list_selected",
None: "status_list_selected",
})
@ -101,6 +109,7 @@ class Timeline(urwid.Columns):
"[A]ccount" if not status.is_mine else "",
"[B]oost",
"[D]elete" if status.is_mine else "",
"[E]dit" if status.is_mine else "",
"B[o]okmark",
"[F]avourite",
"[V]iew",
@ -140,6 +149,16 @@ class Timeline(urwid.Columns):
def modified(self):
"""Called when the list focus switches to a new status"""
status, index, count = self.get_focused_status_with_counts()
if image_support_enabled:
clear_op = getattr(self.tui.screen, "clear_images", None)
# term-image's screen implementation has clear_images(),
# urwid's implementation does not.
# TODO: it would be nice not to check this each time thru
if callable(clear_op):
self.tui.screen.clear_images()
self.draw_status_details(status)
self._emit("focus")
@ -189,6 +208,11 @@ class Timeline(urwid.Columns):
self.tui.show_delete_confirmation(status)
return
if key in ("e", "E"):
if status.is_mine:
self.tui.async_edit(status)
return
if key in ("f", "F"):
self.tui.async_toggle_favourite(self, status)
return
@ -276,7 +300,7 @@ class Timeline(urwid.Columns):
def get_status_index(self, id):
# TODO: This is suboptimal, consider a better way
for n, status in enumerate(self.statuses):
for n, status in enumerate(self.statuses.copy()):
if status.id == id:
return n
raise ValueError("Status with ID {} not found".format(id))
@ -300,6 +324,27 @@ class Timeline(urwid.Columns):
if index == self.status_list.body.focus:
self.draw_status_details(status)
def update_status_image(self, status, path, placeholder_index):
"""Replace image placeholder with image widget and redraw"""
index = self.get_status_index(status.id)
assert self.statuses[index].id == status.id # Sanity check
# get the image and replace the placeholder with a graphics widget
img = None
if hasattr(self, "images"):
try:
img = self.images[(str(hash(path)))]
except KeyError:
pass
if img:
try:
status.placeholders[placeholder_index]._set_original_widget(
graphics_widget(img, image_format=self.tui.options.image_format, corner_radius=10))
except IndexError:
# ignore IndexErrors.
pass
def remove_status(self, status):
index = self.get_status_index(status.id)
assert self.statuses[index].id == status.id # Sanity check
@ -312,24 +357,94 @@ class Timeline(urwid.Columns):
class StatusDetails(urwid.Pile):
def __init__(self, timeline: Timeline, status: Optional[Status]):
self.status = status
self.timeline = timeline
if self.status:
self.status.placeholders = []
self.followed_accounts = timeline.tui.followed_accounts
self.options = timeline.tui.options
reblogged_by = status.author if status and status.reblog else None
widget_list = list(self.content_generator(status.original, reblogged_by)
if status else ())
return super().__init__(widget_list)
def image_widget(self, path, rows=None, aspect=None) -> urwid.Widget:
"""Returns a widget capable of displaying the image
path is required; URL to image
rows, if specfied, sets a fixed number of rows. Or:
aspect, if specified, calculates rows based on pane width
and the aspect ratio provided"""
if not rows:
if not aspect:
aspect = 3 / 2 # reasonable default
screen_rows = screen.get_cols_rows()[1]
if self.timeline.can_render_pixels:
# for pixel-rendered images,
# image rows should be 33% of the available screen
# but in no case fewer than 10
rows = max(10, math.floor(screen_rows * .33))
else:
# for cell-rendered images,
# use the max available columns
# and calculate rows based on the image
# aspect ratio
cols = math.floor(0.55 * screen.get_cols_rows()[0])
rows = math.ceil((cols / 2) / aspect)
# if the calculated rows are more than will
# fit on one screen, reduce to one screen of rows
rows = min(screen_rows - 6, rows)
# but in no case fewer than 10 rows
rows = max(rows, 10)
img = None
if hasattr(self.timeline, "images"):
try:
img = self.timeline.images[(str(hash(path)))]
except KeyError:
pass
if img:
return (urwid.BoxAdapter(
graphics_widget(img, image_format=self.timeline.tui.options.image_format, corner_radius=10), rows))
else:
placeholder = urwid.BoxAdapter(urwid.SolidFill(fill_char=" "), rows)
self.status.placeholders.append(placeholder)
if image_support_enabled():
self.timeline.tui.async_load_image(self.timeline, self.status, path, len(self.status.placeholders) - 1)
return placeholder
def author_header(self, reblogged_by):
avatar_url = self.status.original.data["account"]["avatar"]
if avatar_url and image_support_enabled():
aimg = self.image_widget(avatar_url, 2)
account_color = ("highlight" if self.status.original.author.account in
self.timeline.tui.followed_accounts else "account")
atxt = urwid.Pile([("pack", urwid.Text(("bold", self.status.original.author.display_name))),
("pack", urwid.Text((account_color, self.status.original.author.account)))])
if image_support_enabled():
columns = urwid.Columns([aimg, ("weight", 9999, atxt)], dividechars=1, min_width=5)
else:
columns = urwid.Columns([("weight", 9999, atxt)], dividechars=1, min_width=5)
return columns
def content_generator(self, status, reblogged_by):
if reblogged_by:
text = "{} boosted".format(reblogged_by.display_name or reblogged_by.username)
yield ("pack", urwid.Text(("dim", text)))
reblogger_name = (reblogged_by.display_name
if reblogged_by.display_name
else reblogged_by.username)
text = f"{reblogger_name} boosted"
yield urwid.Text(("dim", text))
yield ("pack", urwid.AttrMap(urwid.Divider("-"), "dim"))
if status.author.display_name:
yield ("pack", urwid.Text(("bold", status.author.display_name)))
account_color = "highlight" if status.author.account in self.followed_accounts else "account"
yield ("pack", urwid.Text((account_color, status.author.account)))
yield self.author_header(reblogged_by)
yield ("pack", urwid.Divider())
if status.data["spoiler_text"]:
@ -337,9 +452,12 @@ class StatusDetails(urwid.Pile):
yield ("pack", urwid.Divider())
# Show content warning
if status.data["spoiler_text"] and not status.show_sensitive:
if status.data["spoiler_text"] and not status.show_sensitive and not self.options.always_show_sensitive:
yield ("pack", urwid.Text(("content_warning", "Marked as sensitive. Press S to view.")))
else:
if status.data["spoiler_text"]:
yield ("pack", urwid.Text(("content_warning", "Marked as sensitive.")))
content = status.original.translation if status.original.show_translation else status.data["content"]
widgetlist = html_to_widgets(content)
@ -353,7 +471,27 @@ class StatusDetails(urwid.Pile):
yield ("pack", urwid.Text([("bold", "Media attachment"), " (", m["type"], ")"]))
if m["description"]:
yield ("pack", urwid.Text(m["description"]))
yield ("pack", url_to_widget(m["url"]))
if m["url"]:
if m["url"].lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp')):
yield urwid.Text("")
try:
aspect = float(m["meta"]["original"]["aspect"])
except Exception:
aspect = None
if image_support_enabled():
yield self.image_widget(m["url"], aspect=aspect)
yield urwid.Divider()
# video media may include a preview URL, show that as a fallback
elif m["preview_url"].lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp')):
yield urwid.Text("")
try:
aspect = float(m["meta"]["small"]["aspect"])
except Exception:
aspect = None
if image_support_enabled():
yield self.image_widget(m["preview_url"], aspect=aspect)
yield urwid.Divider()
yield ("pack", url_to_widget(m["url"]))
poll = status.original.data.get("poll")
if poll:
@ -388,6 +526,8 @@ class StatusDetails(urwid.Pile):
yield ("pack", urwid.Text([
("status_detail_timestamp", f"{status.created_at.strftime('%Y-%m-%d %H:%M')} "),
("status_detail_timestamp",
f"(edited {status.edited_at.strftime('%Y-%m-%d %H:%M')}) " if status.edited_at else ""),
("status_detail_bookmarked" if status.bookmarked else "dim", "b "),
("dim", f"{status.data['replies_count']} "),
("highlight" if status.reblogged else "dim", f"{status.data['reblogs_count']} "),
@ -403,7 +543,7 @@ class StatusDetails(urwid.Pile):
def build_linebox(self, contents):
contents = urwid.Pile(list(contents))
contents = urwid.Padding(contents, left=1, right=1)
return urwid.LineBox(contents)
return RoundedLineBox(contents)
def card_generator(self, card):
yield urwid.Text(("card_title", card["title"].strip()))
@ -415,6 +555,15 @@ class StatusDetails(urwid.Pile):
yield urwid.Text("")
yield url_to_widget(card["url"])
if card["image"] and image_support_enabled():
if card["image"].lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp')):
yield urwid.Text("")
try:
aspect = int(card["width"]) / int(card["height"])
except Exception:
aspect = None
yield self.image_widget(card["image"], aspect=aspect)
def poll_generator(self, poll):
for idx, option in enumerate(poll["options"]):
perc = (round(100 * option["votes_count"] / poll["votes_count"])
@ -442,10 +591,10 @@ class StatusDetails(urwid.Pile):
class StatusListItem(SelectableColumns):
def __init__(self, status, relative_datetimes):
edited_at = status.data.get("edited_at")
edited_at = status.original.edited_at
# TODO: hacky implementation to avoid creating conflicts for existing
# pull reuqests, refactor when merged.
# pull requests, refactor when merged.
created_at = (
time_ago(status.created_at).ljust(3, " ")
if relative_datetimes
@ -456,7 +605,7 @@ class StatusListItem(SelectableColumns):
favourited = ("highlight", "") if status.original.favourited else " "
reblogged = ("highlight", "") if status.original.reblogged else " "
is_reblog = ("dim", "") if status.reblog else " "
is_reply = ("dim", "") if status.original.in_reply_to else " "
is_reply = ("dim", " ") if status.original.in_reply_to else " "
return super().__init__([
("pack", SelectableText(("status_list_timestamp", created_at), wrap="clip")),

Wyświetl plik

@ -1,7 +1,8 @@
import base64
import re
import sys
import urwid
from collections import OrderedDict
from functools import reduce
from html.parser import HTMLParser
from typing import List
@ -109,3 +110,33 @@ def deep_get(adict: dict, path: List[str], default=None):
path,
adict
)
class LRUCache(OrderedDict):
"""Dict with a limited size, ejecting LRUs as needed.
Default max size = 10Mb"""
def __init__(self, *args, cache_max_bytes: int = 1024 * 1024 * 10, **kwargs):
assert cache_max_bytes > 0
self.total_value_size = 0
self.cache_max_bytes = cache_max_bytes
super().__init__(*args, **kwargs)
def __setitem__(self, key: str, value):
if key in self:
self.total_value_size -= sys.getsizeof(super().__getitem__(key).tobytes())
self.total_value_size += sys.getsizeof(value.tobytes())
super().__setitem__(key, value)
super().move_to_end(key)
while self.total_value_size > self.cache_max_bytes:
old_key, value = next(iter(self.items()))
sz = sys.getsizeof(value.tobytes())
super().__delitem__(old_key)
self.total_value_size -= sz
def __getitem__(self, key: str):
val = super().__getitem__(key)
super().move_to_end(key)
return val

Wyświetl plik

@ -67,3 +67,41 @@ class RadioButton(urwid.AttrWrap):
button = urwid.RadioButton(*args, **kwargs)
padding = urwid.Padding(button, width=len(args[1]) + 4)
return super().__init__(padding, "button", "button_focused")
class ModalBox(urwid.Frame):
def __init__(self, message):
text = urwid.Text(message)
filler = urwid.Filler(text, valign='top', top=1, bottom=1)
padding = urwid.Padding(filler, left=1, right=1)
return super().__init__(padding)
class RoundedLineBox(urwid.LineBox):
"""LineBox that defaults to rounded corners."""
def __init__(self,
original_widget,
title="",
title_align="center",
title_attr=None,
tlcorner="\u256d",
tline="",
lline="",
trcorner="\u256e",
blcorner="\u2570",
rline="",
bline="",
brcorner="\u256f",
) -> None:
return super().__init__(original_widget,
title,
title_align,
title_attr,
tlcorner,
tline,
lline,
trcorner,
blcorner,
rline,
bline,
brcorner)

Wyświetl plik

@ -1,147 +0,0 @@
# Taken from https://github.com/rossmacarthur/typing-compat/
# TODO: Remove once the minimum python version is increased to 3.8
#
# Licensed under the MIT license
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# flake8: noqa
import collections
import typing
__all__ = ['get_args', 'get_origin']
__title__ = 'typing-compat'
__version__ = '0.1.0'
__url__ = 'https://github.com/rossmacarthur/typing-compat'
__author__ = 'Ross MacArthur'
__author_email__ = 'ross@macarthur.io'
__description__ = 'Python typing compatibility library'
try:
# Python >=3.8 should have these functions already
from typing import get_args as _get_args # novermin
from typing import get_origin as _get_origin # novermin
except ImportError:
if hasattr(typing, '_GenericAlias'): # Python 3.7
def _get_origin(tp):
"""Copied from the Python 3.8 typing module"""
if isinstance(tp, typing._GenericAlias):
return tp.__origin__
if tp is typing.Generic:
return typing.Generic
return None
def _get_args(tp):
"""Copied from the Python 3.8 typing module"""
if isinstance(tp, typing._GenericAlias):
res = tp.__args__
if (
get_origin(tp) is collections.abc.Callable
and res[0] is not Ellipsis
):
res = (list(res[:-1]), res[-1])
return res
return ()
else: # Python <3.7
def _resolve_via_mro(tp):
if hasattr(tp, '__mro__'):
for t in tp.__mro__:
if t.__module__ in ('builtins', '__builtin__') and t is not object:
return t
return tp
def _get_origin(tp):
"""Emulate the behaviour of Python 3.8 typing module"""
if isinstance(tp, typing._ClassVar):
return typing.ClassVar
elif isinstance(tp, typing._Union):
return typing.Union
elif isinstance(tp, typing.GenericMeta):
if hasattr(tp, '_gorg'):
return _resolve_via_mro(tp._gorg)
else:
while tp.__origin__ is not None:
tp = tp.__origin__
return _resolve_via_mro(tp)
elif hasattr(typing, '_Literal') and isinstance(tp, typing._Literal): # novermin
return typing.Literal # novermin
def _normalize_arg(args):
if isinstance(args, tuple) and len(args) > 1:
base, rest = args[0], tuple(_normalize_arg(arg) for arg in args[1:])
if isinstance(base, typing.CallableMeta):
return typing.Callable[list(rest[:-1]), rest[-1]]
elif isinstance(base, (typing.GenericMeta, typing._Union)):
return base[rest]
return args
def _get_args(tp):
"""Emulate the behaviour of Python 3.8 typing module"""
if isinstance(tp, typing._ClassVar):
return (tp.__type__,)
elif hasattr(tp, '_subs_tree'):
tree = tp._subs_tree()
if isinstance(tree, tuple) and len(tree) > 1:
if isinstance(tree[0], typing.CallableMeta) and len(tree) == 2:
return ([], _normalize_arg(tree[1]))
return tuple(_normalize_arg(arg) for arg in tree[1:])
return ()
def get_origin(tp):
"""
Get the unsubscripted version of a type.
This supports generic types, Callable, Tuple, Union, Literal, Final and
ClassVar. Returns None for unsupported types.
Examples:
get_origin(Literal[42]) is Literal
get_origin(int) is None
get_origin(ClassVar[int]) is ClassVar
get_origin(Generic) is Generic
get_origin(Generic[T]) is Generic
get_origin(Union[T, int]) is Union
get_origin(List[Tuple[T, T]][int]) == list
"""
return _get_origin(tp)
def get_args(tp):
"""
Get type arguments with all substitutions performed.
For unions, basic simplifications used by Union constructor are performed.
Examples:
get_args(Dict[str, int]) == (str, int)
get_args(int) == ()
get_args(Union[int, Union[T, int], str][int]) == (int, str)
get_args(Union[int, Tuple[T, int]][str]) == (int, Tuple[str, int])
get_args(Callable[[], T][int]) == ([], int)
"""
return _get_args(tp)

Wyświetl plik

@ -1,26 +1,22 @@
import click
import os
import re
import socket
import subprocess
import tempfile
import unicodedata
import warnings
from bs4 import BeautifulSoup
from typing import Any, Dict, List
import click
from toot.exceptions import ConsoleError
from typing import Any, Dict, Generator, List, Optional
from urllib.parse import urlparse, urlencode, quote, unquote
def str_bool(b):
def str_bool(b: bool) -> str:
"""Convert boolean to string, in the way expected by the API."""
return "true" if b else "false"
def str_bool_nullable(b):
def str_bool_nullable(b: Optional[bool]) -> Optional[str]:
"""Similar to str_bool, but leave None as None"""
return None if b is None else str_bool(b)
@ -34,7 +30,7 @@ def parse_html(html: str) -> BeautifulSoup:
return BeautifulSoup(html.replace("&apos;", "'"), "html.parser")
def get_text(html):
def get_text(html: str) -> str:
"""Converts html to text, strips all tags."""
text = parse_html(html).get_text()
return unicodedata.normalize("NFKC", text)
@ -53,7 +49,7 @@ def html_to_paragraphs(html: str) -> List[List[str]]:
return [[get_text(line) for line in p] for p in paragraphs]
def format_content(content):
def format_content(content: str) -> Generator[str, None, None]:
"""Given a Status contents in HTML, converts it into lines of plain text.
Returns a generator yielding lines of content.
@ -73,25 +69,12 @@ def format_content(content):
first = False
def domain_exists(name):
try:
socket.gethostbyname(name)
return True
except OSError:
return False
def assert_domain_exists(domain):
if not domain_exists(domain):
raise ConsoleError("Domain {} not found".format(domain))
EOF_KEY = "Ctrl-Z" if os.name == 'nt' else "Ctrl-D"
def multiline_input():
def multiline_input() -> str:
"""Lets user input multiple lines of text, terminated by EOF."""
lines = []
lines: List[str] = []
while True:
try:
lines.append(input())

Wyświetl plik

@ -4,7 +4,7 @@ import os
from datetime import datetime, timezone
def parse_datetime(value):
def parse_datetime(value: str) -> datetime:
"""Returns an aware datetime in local timezone"""
dttm = datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%f%z")

Wyświetl plik

@ -173,5 +173,5 @@ LANGUAGES = {
}
def language_name(code):
def language_name(code: str) -> str:
return LANGUAGES.get(code, code)