Porównaj commity

...

211 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
Ivan Habunek 41b77cc9de
Help with list command discovery 2023-12-27 10:17:14 +01:00
Ivan Habunek 556741e864
Don't show warning for [tui.palette] setting section 2023-12-27 10:17:14 +01:00
Ivan Habunek d1d74f47d8
Include description in tag commit message 2023-12-27 10:17:14 +01:00
Ivan Habunek 94d7532929
Set release date 2023-12-27 10:17:14 +01:00
Ivan Habunek eeb90dc21c
Remove --quiet flag 2023-12-27 09:47:51 +01:00
Sandra Snan 44b6f9fcf4
Drop Pleroma Emoji Reactions
I made this a list so you can add other types to drop easily but if
this is premature generalization we could make it a scalar instead.
2023-12-26 09:49:08 +01:00
Ivan Habunek 11bc102cc8
Read [tui] section to preserve BC 2023-12-26 09:48:31 +01:00
Ivan Habunek 9f0c94bce1
Update readme 2023-12-22 09:44:13 +01:00
Ivan Habunek d9c6bf79c8
Fix urwid stalling until input received
fixes #364
2023-12-19 11:10:49 +01:00
Ivan Habunek 561506ee2d
Enable getting public timelines without logging in 2023-12-17 09:56:39 +01:00
Ivan Habunek 59efff5abc
Use context manager to edit config
This simplies the code a bit and resolves some type hinting issues.
2023-12-17 09:42:09 +01:00
Ivan Habunek 1dac093be7
Add --json option to lists commands 2023-12-14 14:10:53 +01:00
Ivan Habunek 438a8ab206
Make instance work without logging in 2023-12-14 13:06:06 +01:00
Ivan Habunek 6cf0e84d7a
Merge pull request #445 from strk/phony-bundle
Make bundle rule phony
2023-12-14 13:03:41 +01:00
Sandro Santilli 81c87c5709 Make bundle rule phony 2023-12-14 12:53:32 +01:00
Ivan Habunek 3399c8763d
Merge pull request #428 from ihabunek/click
Migrate to Click
2023-12-14 12:07:29 +01:00
Ivan Habunek 44ea2e8e6f
Don't ignore the whole file by flake8 2023-12-14 11:57:33 +01:00
Ivan Habunek f72e4ba844
Move code from toot.tui.base to toot.tui 2023-12-14 11:35:52 +01:00
Ivan Habunek 2f3f686a00
Rework how app, user are passed to context 2023-12-14 10:11:09 +01:00
Ivan Habunek 164016481d
Replace lists commands with subcommands 2023-12-13 16:14:46 +01:00
Ivan Habunek 7ba2d9cce5
Use click echo instead of print 2023-12-13 15:35:58 +01:00
Ivan Habunek ad7cfd44d4
Update changelog 2023-12-13 15:35:58 +01:00
Ivan Habunek fab23b9069
Reorganize cli imports
The old way did not allow for having multiple commands of the same name
2023-12-13 15:00:47 +01:00
Ivan Habunek 120545865b
Bump version to 0.40.0 2023-12-13 08:40:30 +01:00
Ivan Habunek 01f3370b89
Add `tags info` command 2023-12-13 08:40:02 +01:00
Ivan Habunek 381e3583ef
Add featured tag commands 2023-12-13 08:40:02 +01:00
Ivan Habunek 743dfd715e
Change `toot tags` to `toot tags followed` 2023-12-13 08:40:02 +01:00
Ivan Habunek a0caa88ffe
Add insurance policy 2023-12-13 08:40:02 +01:00
Ivan Habunek c7e01c77f2
Add --json option to tag commands 2023-12-13 08:40:02 +01:00
Ivan Habunek 63691a3637
Allow editor when not in tty
I was told there are legitimate use cases I was not aware of.
2023-12-11 13:59:05 +01:00
Ivan Habunek 0f4f0b3863
Don't page lists, they don't support paging 2023-12-08 08:44:24 +01:00
Ivan Habunek 9098279d40
Replace tags_* commands with a group 2023-12-08 08:23:17 +01:00
Ivan Habunek 875bf2d86a
Add docs for environment variables 2023-12-07 20:05:58 +01:00
Ivan Habunek 0848a6f7df
Add shell completion for account names 2023-12-07 19:45:13 +01:00
Ivan Habunek c7b5669c78
Add docs for shell completion 2023-12-07 19:45:13 +01:00
Ivan Habunek bbf67c6736
Pass tui options through cli options 2023-12-07 19:27:26 +01:00
Ivan Habunek ac77ea75ce
Remove unused code 2023-12-07 19:11:12 +01:00
Ivan Habunek 92dbdf5c3e
Move docs server to port 8000
By default it's on 3000 which is the same as mastodon.
2023-12-07 18:24:06 +01:00
Ivan Habunek 11cfa5834b
Remove default from environment variable
Click already does that for us.
2023-12-07 10:23:17 +01:00
Ivan Habunek 8e7a90e8da
Remove unused code 2023-12-07 10:23:05 +01:00
Ivan Habunek a4cf678b15
Extract print_divider 2023-12-07 10:06:39 +01:00
Ivan Habunek bf5eb9e7f8
Add --width option 2023-12-07 10:03:33 +01:00
Ivan Habunek ac7964a7b4
Use cached fn to get settings 2023-12-05 12:00:45 +01:00
Ivan Habunek bbb5658781
Overhaul output to use click 2023-12-05 11:55:09 +01:00
Ivan Habunek e89cc6d590
Load command defaults from settings 2023-12-05 10:56:28 +01:00
Ivan Habunek d91f3477a8
Simplify main
No need to handle this stuff here
2023-12-05 10:45:18 +01:00
Ivan Habunek 78f994c0f1
Make toot instance work with instance domain name 2023-12-05 10:18:34 +01:00
Ivan Habunek b539c933ef
Respect --no-color 2023-12-05 09:59:40 +01:00
Ivan Habunek a653b557b4
Fix formatting 2023-12-05 09:25:02 +01:00
Ivan Habunek a8aeb32e18
Fix typing not to break older python versions 2023-12-05 09:15:39 +01:00
Ivan Habunek b9aae37e7d
Limit test files
...so that things from bundle are not picked up by mistake
2023-12-05 08:58:31 +01:00
Ivan Habunek 05dbd7bb57
Fix bug in media upload 2023-12-05 08:58:18 +01:00
Ivan Habunek b85daabb9d
Add missing package to discovery 2023-12-05 08:52:12 +01:00
Ivan Habunek e8dac36de3
Add `make bundle` for creating a pyz bundle 2023-12-05 08:52:07 +01:00
Ivan Habunek 24866bd4e4
Improve types 2023-12-05 08:15:27 +01:00
Ivan Habunek b9d0c1f7c2
Delete unused code 2023-12-04 18:46:45 +01:00
Ivan Habunek eaaa14cfc2
Use click.echo to output text 2023-12-04 18:45:40 +01:00
Ivan Habunek 452b98d2ad
Delete old command implementations 2023-12-04 17:51:06 +01:00
Ivan Habunek 4dfab69f3b
Add tui command 2023-12-03 13:53:52 +01:00
Ivan Habunek 3947b28de5
Add upload command 2023-12-03 13:45:24 +01:00
Ivan Habunek 84396fefc2
Improve variable naming 2023-12-03 13:32:51 +01:00
Ivan Habunek 2429d9f751
Migrate timeline commands 2023-12-03 13:29:31 +01:00
Ivan Habunek 69a11f3569
Remove old mock tests
These will be replaced by simpler and more useful integration tests.
2023-12-02 11:10:36 +01:00
Ivan Habunek d8c7084678
Migrate auth commands 2023-11-30 20:12:04 +01:00
Ivan Habunek 696a9dcc2e
Add type hints for App and User 2023-11-30 20:10:19 +01:00
Ivan Habunek e5c8fc4f77
Extend instance tests 2023-11-30 20:08:59 +01:00
Ivan Habunek 6c9b939175
Better test file name 2023-11-30 12:12:41 +01:00
Ivan Habunek 16e28d02c6
Fix getting the instance domain name
This used to return 3000 when running locally on localhost:3000
2023-11-30 11:58:57 +01:00
Ivan Habunek 5d9ee44cec
Migrate list commands 2023-11-29 12:11:41 +01:00
Ivan Habunek c0eb76751f
Migrate update_account command 2023-11-28 16:56:53 +01:00
Ivan Habunek 3dc5d35751
Migrate account commands 2023-11-28 14:05:44 +01:00
Ivan Habunek 51fcd60eb5
Migrate status commands 2023-11-28 12:26:08 +01:00
Ivan Habunek d6678e0498
Migrate post command 2023-11-28 11:53:43 +01:00
Ivan Habunek 096ec09684
Add toot --version 2023-11-28 10:13:20 +01:00
Ivan Habunek 9ecfa79db8
Setup click, migrate read commands 2023-11-28 10:13:05 +01:00
Ivan Habunek 1c5abb8419
Improve from_dict performance by caching fields 2023-11-26 09:16:21 +01:00
Ivan Habunek 48d9caef05
Improve typing in wcwidth module 2023-11-24 09:52:41 +01:00
Ivan Habunek 509afd16a8
Add release date 2023-11-23 11:34:14 +01:00
Ivan Habunek a6bbe97332
Add changelog, bump version 2023-11-22 08:50:17 +01:00
Ivan Habunek 7929919ffc
Add --json option to update_account 2023-11-22 08:41:15 +01:00
Ivan Habunek e961bd696d
Make account optional in following and followers 2023-11-22 08:22:21 +01:00
Ivan Habunek 443f9445b1
Add --json option to account commands 2023-11-21 18:16:37 +01:00
Ivan Habunek 016ae25569
Add --json option to various status commands 2023-11-21 16:51:02 +01:00
Ivan Habunek 4203e8d313
Dedupe duplicate function 2023-11-21 16:51:02 +01:00
Ivan Habunek 7793d4499a
Add --json option to post command 2023-11-21 16:51:02 +01:00
Ivan Habunek 3530553a06
Add --json option to status and thread commands 2023-11-21 16:51:02 +01:00
Ivan Habunek ae7a36b8d8
Add --json option to search command 2023-11-21 16:50:55 +01:00
Ivan Habunek 0c37716de1
Add --json option to instance command 2023-11-21 16:50:19 +01:00
Ivan Habunek 57be6beae8
Log request exceptions 2023-11-21 16:50:19 +01:00
Ivan Habunek 9664d71b57
Make get_instance return the response instead of json 2023-11-21 16:50:19 +01:00
Ivan Habunek 3f680312c6
Remove monkeypatched auth tests
Not very useful, tested in integration tests.
2023-11-21 16:50:19 +01:00
Ivan Habunek 45962b27c3
Fix search sending type=None if not specified 2023-11-21 16:48:12 +01:00
Ivan Habunek 540851713c
Remove travis config 2023-11-21 16:48:11 +01:00
Ivan Habunek ec90077255
Merge pull request #406 from snan/venv-docs
Clarify misleading venv docs
2023-11-19 13:54:11 +01:00
Ivan Habunek cd03486a25
Remove unused imports 2023-11-19 12:48:22 +01:00
Ivan Habunek 5a83cd7d3b
Read media viewer from settings 2023-11-19 12:15:26 +01:00
Ivan Habunek ef19449190
Load followed accounts after timeline
This way we don't have to wait for them to load, which may take a while
due to paging.
2023-11-19 09:12:42 +01:00
Ivan Habunek 9808784645
Add a config file for vermin 2023-11-18 22:20:06 +01:00
Ivan Habunek 4cd83daf4b
Move requirements files into setup.py 2023-11-18 22:16:37 +01:00
Ivan Habunek e9daaf6000
Fix warnings 2023-11-18 22:02:11 +01:00
Ivan Habunek 7141d83c6f
Add setuptools to test requirements
Python 3.12 does not have setuptools installed by default.
2023-11-18 16:06:59 +01:00
Ivan Habunek b9f092c0e1
Add testing on python 3.12 2023-11-18 15:53:53 +01:00
Ivan Habunek 8c3fd12005
Fix style 2023-11-18 15:53:14 +01:00
Ivan Habunek 4a3b14313c
Fix compat with older versions of python 2023-11-18 15:48:28 +01:00
Ivan Habunek 0265f7e0b7
Fix tests 2023-11-18 15:44:50 +01:00
Ivan Habunek 3de561a060
Add --json argument to whois command 2023-11-18 15:42:04 +01:00
Ivan Habunek dd16627c89
Update print_account to take an Account object 2023-11-18 15:42:02 +01:00
Ivan Habunek 2c4f7e17c9
Add --json option to whoami command 2023-11-18 15:40:51 +01:00
Ivan Habunek 6cdba16fcf
Make verify_credentials return the http response
Required if we want to emit json without decoding it
2023-11-18 15:40:50 +01:00
Ivan Habunek 317840b019
Merge pull request #415 from ihabunek/danschwarz-richtext3
Add support for rich text
2023-11-18 15:40:35 +01:00
Ivan Habunek fe8b441b5b
Add hack to work around a pleroma bug 2023-11-18 12:32:35 +01:00
Ivan Habunek 8d1edd5374
Fix compat with older python versions 2023-11-18 11:25:52 +01:00
Ivan Habunek 59adec3e55
Improve error when conversion fails 2023-11-18 11:18:30 +01:00
Ivan Habunek 05c5bcb723
Convert datetimes to local timezone by default 2023-11-18 10:25:52 +01:00
Daniel Schwarz 9b9c153531 Fixed github build to include richtext "extra"
which pulls in urwidgets dependeency, required for builds
2023-11-16 20:35:29 -05:00
Daniel Schwarz 732b9feed5 Added test for toot.utils.urlencode_url(...) 2023-11-16 20:24:53 -05:00
Daniel Schwarz 584f598b5a
Merge pull request #412 from ihabunek/rich
Rich text simplification
2023-11-16 17:29:34 -05:00
Ivan Habunek 414d9e8ff2
Start testing richtext 2023-11-16 12:29:37 +01:00
Ivan Habunek bc542b5e37
Add richtext package 2023-11-16 11:51:11 +01:00
Ivan Habunek 57cfd41613
Remove old stubs 2023-11-16 11:50:25 +01:00
Ivan Habunek d6ff3cc3a8
Extract url_to_widget, add fallback 2023-11-16 11:46:54 +01:00
Ivan Habunek e5ac82bb01
Add fallback for html_to_widgets
Remove has_urwidgets since we no longer need to worry if we have
urwidgets in the richtext module.
2023-11-16 11:36:18 +01:00
Ivan Habunek f96b1b722c
Move richtext to it's own module
This is the first step towards easier stubbing
2023-11-16 11:12:54 +01:00
Ivan Habunek f50dea1175
Simplify text_to_widget
This was doing double regex matching, calling parse_text was not needed
at all.
2023-11-16 11:09:32 +01:00
Ivan Habunek 073dd3025c
Remove the ContentParser class, use functions instead
It did not help, just added to the indent.
2023-11-06 18:23:35 +01:00
Ivan Habunek a544453338
Remove magic lookup
Having the choice explicit makes the code easier to read.
2023-11-06 18:22:09 +01:00
Ivan Habunek ce6faccb99
Extract render method 2023-11-06 17:43:02 +01:00
Ivan Habunek 2aba3f93f9
Extract block tags 2023-11-06 09:56:12 +01:00
Ivan Habunek a8b4c79716
Eliminate constructor 2023-11-06 09:36:30 +01:00
Ivan Habunek 199a96625b
Extract parsing html 2023-11-04 07:53:40 +01:00
Ivan Habunek d91c73520e
Better function name 2023-11-04 07:38:47 +01:00
Ivan Habunek a9ef96c31b
Cleanup formatting 2023-11-04 07:26:45 +01:00
Daniel Schwarz 06167a5bc9 typo fix in requirements.txt 2023-09-29 07:18:59 -04:00
Daniel Schwarz 89e905cd8b added urwidgets as an optional depenency for 'hyperlinks' extra 2023-09-28 14:22:59 -04:00
Sandra Snan f45d5726ba Clarify misleading venv docs
On my zsh I was still getting /usr/bin/toot even in the venv.
2023-09-23 17:58:07 +02:00
Daniel Schwarz 0f39b1087f Support to display a limited set of HTML tags
HTML tag support is aligned with Mastodon 4.2 supported tags.
This code introduces a soft dependency on the urwidgets library.
If urwidgets is not available, HTML tags are still supported,
but hyperlinks are not underlined using the OCS 8 terminal
feature (on supported terminals).
2023-09-22 21:32:19 -04:00
Denis Laxalde 2199ca18b5 Document the [M]edia action 2020-01-26 11:23:39 +01:00
86 zmienionych plików z 6120 dodań i 3783 usunięć

Wyświetl plik

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

Wyświetl plik

@ -7,25 +7,25 @@ jobs:
runs-on: ubuntu-22.04
strategy:
matrix:
python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"]
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
run: |
python -m pip install --upgrade pip
pip install -e .
pip install -r requirements-test.txt
pip install -e ".[test,richtext]"
- name: Run tests
run: |
pytest
- name: Validate minimum required version
run: |
vermin --target=3.7 --no-tips .
vermin toot
- name: Check style
run: |
flake8

10
.gitignore vendored
Wyświetl plik

@ -6,12 +6,14 @@
/.env
/.envrc
/.pytest_cache/
/book
/build/
/bundle/
/dist/
/htmlcov/
/tmp/
/toot-*.tar.gz
debug.log
/pyrightconfig.json
/tmp/
/toot-*.pyz
/toot-*.tar.gz
/venv/
/book
debug.log

Wyświetl plik

@ -1,13 +0,0 @@
language: python
python:
- "3.4"
- "3.5"
- "3.6"
- "3.7"
- "nightly"
install:
- pip install -e .
script: make test

4
.vermin 100644
Wyświetl plik

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

Wyświetl plik

@ -3,6 +3,82 @@ 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
(https://click.palletsprojects.com/) for creating the command line interface.
This allows for some new features like nested commands, setting parameters via
environment variables, and shell completion. Backward compatibility should be
mostly preserved, except for cases noted below. Please report any issues.
* BREAKING: Remove deprecated `--disable-https` option for `login` and
`login_cli`, pass the base URL instead
* BREAKING: Options `--debug` and `--color` must be specified after `toot` but
before the command
* BREAKING: Option `--quiet` has been removed. Redirect output instead.
* Add passing parameters via environment variables, see:
https://toot.bezdomni.net/environment_variables.html
* Add shell completion, see: https://toot.bezdomni.net/shell_completion.html
* Add `tags info`, `tags featured`, `tags feature`, and `tags unfeature`
commands
* Add `tags followed`, `tags follow`, and `tags unfollow` sub-commands,
deprecate `tags_followed`, `tags_follow`, and `tags tags_unfollow`
* Add `lists accounts`, `lists add`, `lists create`, `lists delete`, `lists
list`, `lists remove` subcommands, deprecate `lists`, `lists_accounts`,
`lists_add`, `lists_create`, `lists_delete`, `lists_remove` commands.
* Add `--json` option to tags and lists commands
* Add `toot --width` option for setting your preferred terminal width
* Add `--media-viewer` and `--colors` options to `toot tui`. These were
previously accessible only via settings.
* TUI: Fix issue where UI did not render until first input (thanks Urwid devs)
**0.39.0 (2023-11-23)**
* Add `--json` option to many commands, this makes them print the JSON data
returned by the server instead of human-readable data. Useful for scripting.
* TUI: Make media viewer configurable in settings, see:
https://toot.bezdomni.net/settings.html#tui-view-images
* TUI: Add rich text rendering (thanks Dan Schwarz)
**0.38.2 (2023-11-16)**
* Fix compatibility with Pleroma (#399, thanks Sandra Snan)
@ -85,7 +161,7 @@ Changelog
* TUI: Hide polls, cards and media attachments for sensitive posts (thanks
Daniel Schwarz)
* TUI: Add bookmarking and bookmark timeline (thanks Daniel Schwarz)
* TUI: Show status visiblity (thanks Lim Ding Wen)
* TUI: Show status visibility (thanks Lim Ding Wen)
* TUI: Reply to original account instead of boosting account (thanks Lim Ding
Wen)
* TUI: Refresh screen after exiting browser, required for text browsers (thanks
@ -113,7 +189,7 @@ Changelog
**0.30.1 (2022-11-30)**
* Remove usage of depreacted `text_url` status field. Fixes posting media
* Remove usage of deprecated `text_url` status field. Fixes posting media
without text.
**0.30.0 (2022-11-29)**
@ -166,7 +242,7 @@ Changelog
(#168)
* Add `--reverse` option to `toot notifications` (#151)
* Fix `toot timeline` to respect `--instance` option
* TUI: Add opton to pin/save tag timelines (#163, thanks @dlax)
* TUI: Add option to pin/save tag timelines (#163, thanks @dlax)
* TUI: Fixed crash on empty timeline (#138, thanks ecs)
**0.26.0 (2020-04-15)**

Wyświetl plik

@ -77,8 +77,9 @@ pip install -r requirements-dev.txt
pip install -r requirements-test.txt
```
While the virtual env is active, running `toot` will execute the one you checked
out. This allows you to make changes and test them.
While the virtual env is active, you can run `./_env/bin/toot` to
execute the one you checked out. This allows you to make changes and
test them.
#### Crafting good commits
@ -110,7 +111,7 @@ these rules for you.
#### Run tests before submitting
You can run code and sytle tests by running:
You can run code and style tests by running:
```
make test

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
@ -10,17 +9,17 @@ publish :
test:
pytest -v
flake8
vermin --target=3.7 --no-tips --violations --exclude-regex venv/.* .
vermin toot
coverage:
coverage erase
coverage run
coverage html
coverage html --omit "toot/tui/*"
coverage report
clean :
find . -name "*pyc" | xargs rm -rf $1
rm -rf build dist MANIFEST htmlcov toot*.tar.gz
rm -rf build dist MANIFEST htmlcov bundle toot*.tar.gz toot*.pyz
changelog:
./scripts/generate_changelog > CHANGELOG.md
@ -30,7 +29,20 @@ docs: changelog
mdbook build
docs-serve:
mdbook serve
mdbook serve --port 8000
docs-deploy: docs
rsync --archive --compress --delete --stats book/ bezdomni:web/toot
.PHONY: bundle
bundle:
mkdir bundle
cp toot/__main__.py bundle
pip install . --target=bundle
rm -rf bundle/*.dist-info
find bundle/ -type d -name "__pycache__" -exec rm -rf {} +
python -m zipapp \
--python "/usr/bin/env python3" \
--output toot-`git describe`.pyz bundle \
--compress
echo "Bundle created: toot-`git describe`.pyz"

Wyświetl plik

@ -1,3 +1,80 @@
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: |
This release includes a rather extensive change to use the Click library
(https://click.palletsprojects.com/) for creating the command line
interface. This allows for some new features like nested commands, setting
parameters via environment variables, and shell completion. Backward
compatibility should be mostly preserved, except for cases noted below.
Please report any issues.
changes:
- "BREAKING: Remove deprecated `--disable-https` option for `login` and `login_cli`, pass the base URL instead"
- "BREAKING: Options `--debug` and `--color` must be specified after `toot` but before the command"
- "BREAKING: Option `--quiet` has been removed. Redirect output instead."
- "Add passing parameters via environment variables, see: https://toot.bezdomni.net/environment_variables.html"
- "Add shell completion, see: https://toot.bezdomni.net/shell_completion.html"
- "Add `tags info`, `tags featured`, `tags feature`, and `tags unfeature` commands"
- "Add `tags followed`, `tags follow`, and `tags unfollow` sub-commands, deprecate `tags_followed`, `tags_follow`, and `tags tags_unfollow`"
- "Add `lists accounts`, `lists add`, `lists create`, `lists delete`, `lists list`, `lists remove` subcommands, deprecate `lists`, `lists_accounts`, `lists_add`, `lists_create`, `lists_delete`, `lists_remove` commands."
- "Add `--json` option to tags and lists commands"
- "Add `toot --width` option for setting your preferred terminal width"
- "Add `--media-viewer` and `--colors` options to `toot tui`. These were previously accessible only via settings."
- "TUI: Fix issue where UI did not render until first input (thanks Urwid devs)"
0.39.0:
date: 2023-11-23
changes:
- "Add `--json` option to many commands, this makes them print the JSON data returned by the server instead of human-readable data. Useful for scripting."
- "TUI: Make media viewer configurable in settings, see: https://toot.bezdomni.net/settings.html#tui-view-images"
- "TUI: Add rich text rendering (thanks Dan Schwarz)"
0.38.2:
date: 2023-11-16
changes:
@ -79,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)"
@ -107,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
@ -154,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

@ -6,6 +6,8 @@
- [Usage](usage.md)
- [Advanced](advanced.md)
- [Settings](settings.md)
- [Shell completion](shell_completion.md)
- [Environment variables](environment_variables.md)
- [TUI](tui.md)
- [Contributing](contributing.md)
- [Documentation](documentation.md)

Wyświetl plik

@ -3,6 +3,82 @@ 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
(https://click.palletsprojects.com/) for creating the command line interface.
This allows for some new features like nested commands, setting parameters via
environment variables, and shell completion. Backward compatibility should be
mostly preserved, except for cases noted below. Please report any issues.
* BREAKING: Remove deprecated `--disable-https` option for `login` and
`login_cli`, pass the base URL instead
* BREAKING: Options `--debug` and `--color` must be specified after `toot` but
before the command
* BREAKING: Option `--quiet` has been removed. Redirect output instead.
* Add passing parameters via environment variables, see:
https://toot.bezdomni.net/environment_variables.html
* Add shell completion, see: https://toot.bezdomni.net/shell_completion.html
* Add `tags info`, `tags featured`, `tags feature`, and `tags unfeature`
commands
* Add `tags followed`, `tags follow`, and `tags unfollow` sub-commands,
deprecate `tags_followed`, `tags_follow`, and `tags tags_unfollow`
* Add `lists accounts`, `lists add`, `lists create`, `lists delete`, `lists
list`, `lists remove` subcommands, deprecate `lists`, `lists_accounts`,
`lists_add`, `lists_create`, `lists_delete`, `lists_remove` commands.
* Add `--json` option to tags and lists commands
* Add `toot --width` option for setting your preferred terminal width
* Add `--media-viewer` and `--colors` options to `toot tui`. These were
previously accessible only via settings.
* TUI: Fix issue where UI did not render until first input (thanks Urwid devs)
**0.39.0 (2023-11-23)**
* Add `--json` option to many commands, this makes them print the JSON data
returned by the server instead of human-readable data. Useful for scripting.
* TUI: Make media viewer configurable in settings, see:
https://toot.bezdomni.net/settings.html#tui-view-images
* TUI: Add rich text rendering (thanks Dan Schwarz)
**0.38.2 (2023-11-16)**
* Fix compatibility with Pleroma (#399, thanks Sandra Snan)
@ -85,7 +161,7 @@ Changelog
* TUI: Hide polls, cards and media attachments for sensitive posts (thanks
Daniel Schwarz)
* TUI: Add bookmarking and bookmark timeline (thanks Daniel Schwarz)
* TUI: Show status visiblity (thanks Lim Ding Wen)
* TUI: Show status visibility (thanks Lim Ding Wen)
* TUI: Reply to original account instead of boosting account (thanks Lim Ding
Wen)
* TUI: Refresh screen after exiting browser, required for text browsers (thanks
@ -113,7 +189,7 @@ Changelog
**0.30.1 (2022-11-30)**
* Remove usage of depreacted `text_url` status field. Fixes posting media
* Remove usage of deprecated `text_url` status field. Fixes posting media
without text.
**0.30.0 (2022-11-29)**
@ -166,7 +242,7 @@ Changelog
(#168)
* Add `--reverse` option to `toot notifications` (#151)
* Fix `toot timeline` to respect `--instance` option
* TUI: Add opton to pin/save tag timelines (#163, thanks @dlax)
* TUI: Add option to pin/save tag timelines (#163, thanks @dlax)
* TUI: Fixed crash on empty timeline (#138, thanks ecs)
**0.26.0 (2020-04-15)**

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

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

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`.
@ -51,6 +51,21 @@ visibility = "unlisted"
scheduled_in = "30 minutes"
```
## TUI view images
> Introduced in toot 0.39.0
You can view images in a toot using an external program by setting the
`tui.media_viewer` option to your desired image viewer. When a toot is focused,
pressing `m` will launch the specified executable giving one or more URLs as
arguments. This works well with image viewers like `feh` which accept URLs as
arguments.
```toml
[tui]
media_viewer = "feh"
```
## TUI color palette
TUI uses Urwid which provides several color modes. See
@ -67,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:

Wyświetl plik

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

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"

2
pytest.ini 100644
Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -1,5 +0,0 @@
requests>=2.13,<3.0
beautifulsoup4>=4.5.0,<5.0
wcwidth>=0.1.7
urwid>=2.0.0,<3.0

Wyświetl plik

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

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,16 +32,8 @@ 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"]
if not isinstance(release_date, date):
@ -50,6 +41,11 @@ if not isinstance(release_date, date):
sys.exit(1)
commit_message = f"toot {version}\n\n"
if description:
lines = textwrap.wrap(description.strip(), 72)
commit_message += "\n".join(lines) + "\n\n"
for c in changes:
lines = textwrap.wrap(c, 70)
initial = True

Wyświetl plik

@ -1,48 +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.38.2',
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.tui', 'toot.utils'],
python_requires=">=3.7",
install_requires=[
"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"
],
entry_points={
'console_scripts': [
'toot=toot.console:main',
],
}
)

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,21 +9,20 @@ your test server and database:
```
export TOOT_TEST_BASE_URL="localhost:3000"
export TOOT_TEST_DATABASE_DSN="dbname=mastodon_development"
```
"""
import re
import json
import os
import psycopg2
import pytest
import re
import typing as t
import uuid
from click.testing import CliRunner, Result
from pathlib import Path
from toot import api, App, User
from toot.console import run_command
from toot.exceptions import ApiError, ConsoleError
from toot.output import print_out
from toot.cli import Context, TootObj
def pytest_configure(config):
@ -31,17 +30,22 @@ 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
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)
instance = api.get_instance(base_url).json()
response = api.create_app(base_url)
return App(instance["uri"], base_url, response["client_id"], response["client_secret"])
@ -50,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
# ------------------------------------------------------------------------------
@ -71,12 +67,10 @@ def confirm_user(email):
# DO NOT USE PUBLIC INSTANCES!!!
@pytest.fixture(scope="session")
def base_url():
base_url = os.getenv("TOOT_TEST_BASE_URL")
if not base_url:
if not TOOT_TEST_BASE_URL:
pytest.skip("Skipping integration tests, TOOT_TEST_BASE_URL not set")
return base_url
return TOOT_TEST_BASE_URL
@pytest.fixture(scope="session")
@ -94,29 +88,57 @@ def friend(app):
return register_account(app)
@pytest.fixture
def run(app, user, capsys):
def _run(command, *params, as_user=None):
# The try/catch duplicates logic from console.main to convert exceptions
# to printed error messages. TODO: could be deduped
try:
run_command(app, as_user or user, command, params or [])
except (ConsoleError, ApiError) as e:
print_out(str(e))
@pytest.fixture(scope="session")
def user_id(app, user):
return api.find_account(app, user, user.username)["id"]
out, err = capsys.readouterr()
assert err == ""
return strip_ansi(out)
@pytest.fixture(scope="session")
def friend_id(app, user, friend):
return api.find_account(app, user, friend.username)["id"]
@pytest.fixture(scope="session", autouse=True)
def testing_env():
os.environ["TOOT_TESTING"] = "true"
@pytest.fixture(scope="session")
def runner():
return CliRunner(mix_stderr=False)
@pytest.fixture
def run(app, user, runner):
def _run(command, *params, input=None) -> Result:
obj = TootObj(test_ctx=Context(app, user))
return runner.invoke(command, params, obj=obj, input=input)
return _run
@pytest.fixture
def run_anon(capsys):
def _run(command, *params):
run_command(None, None, command, params or [])
out, err = capsys.readouterr()
assert err == ""
return strip_ansi(out)
def run_as(app, runner):
def _run_as(user, command, *params, input=None) -> Result:
obj = TootObj(test_ctx=Context(app, user))
return runner.invoke(command, params, obj=obj, input=input)
return _run_as
@pytest.fixture
def run_json(app, user, runner):
def _run_json(command, *params):
obj = TootObj(test_ctx=Context(app, user))
result = runner.invoke(command, params, obj=obj)
assert result.exit_code == 0
return json.loads(result.stdout)
return _run_json
@pytest.fixture
def run_anon(runner):
def _run(command, *params) -> Result:
obj = TootObj(test_ctx=Context(None, None))
return runner.invoke(command, params, obj=obj)
return _run
@ -124,12 +146,6 @@ def run_anon(capsys):
# Utils
# ------------------------------------------------------------------------------
strip_ansi_pattern = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
def strip_ansi(string):
return strip_ansi_pattern.sub("", string).strip()
def posted_status_id(out):
pattern = re.compile(r"Toot posted: http://([^/]+)/([^/]+)/(.+)")
@ -139,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,12 +1,28 @@
import json
from tests.integration.conftest import register_account
from toot import App, User, api, cli
from toot.entities import Account, Relationship, from_dict
def test_whoami(user, run):
out = run("whoami")
def test_whoami(user: User, run):
result = run(cli.read.whoami)
assert result.exit_code == 0
# TODO: test other fields once updating account is supported
out = result.stdout.strip()
assert f"@{user.username}" in out
def test_whois(app, friend, run):
def test_whoami_json(user: User, run):
result = run(cli.read.whoami, "--json")
assert result.exit_code == 0
account = from_dict(Account, json.loads(result.stdout))
assert account.username == user.username
def test_whois(app: App, friend: User, run):
variants = [
friend.username,
f"@{friend.username}",
@ -15,5 +31,244 @@ def test_whois(app, friend, run):
]
for username in variants:
out = run("whois", username)
assert f"@{friend.username}" in out
result = run(cli.read.whois, username)
assert result.exit_code == 0
assert f"@{friend.username}" in result.stdout
def test_following(app: App, user: User, run):
friend = register_account(app)
result = run(cli.accounts.following, user.username)
assert result.exit_code == 0
assert result.stdout.strip() == ""
result = run(cli.accounts.follow, friend.username)
assert result.exit_code == 0
assert result.stdout.strip() == f"✓ You are now following {friend.username}"
result = run(cli.accounts.following, user.username)
assert result.exit_code == 0
assert friend.username in result.stdout.strip()
# If no account is given defaults to logged in user
result = run(cli.accounts.following)
assert result.exit_code == 0
assert friend.username in result.stdout.strip()
result = run(cli.accounts.unfollow, friend.username)
assert result.exit_code == 0
assert result.stdout.strip() == f"✓ You are no longer following {friend.username}"
result = run(cli.accounts.following, user.username)
assert result.exit_code == 0
assert result.stdout.strip() == ""
def test_following_case_insensitive(user: User, friend: User, run):
assert friend.username != friend.username.upper()
result = run(cli.accounts.follow, friend.username.upper())
assert result.exit_code == 0
out = result.stdout.strip()
assert out == f"✓ You are now following {friend.username.upper()}"
def test_following_not_found(run):
result = run(cli.accounts.follow, "bananaman")
assert result.exit_code == 1
assert result.stderr.strip() == "Error: Account not found"
result = run(cli.accounts.unfollow, "bananaman")
assert result.exit_code == 1
assert result.stderr.strip() == "Error: Account not found"
def test_following_json(app: App, user: User, user_id, run_json):
friend = register_account(app)
result = run_json(cli.accounts.following, user.username, "--json")
assert result == []
result = run_json(cli.accounts.followers, friend.username, "--json")
assert result == []
result = run_json(cli.accounts.follow, friend.username, "--json")
relationship = from_dict(Relationship, result)
assert relationship.following is True
[result] = run_json(cli.accounts.following, user.username, "--json")
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, "--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")
account = from_dict(Account, result)
assert account.acct == user.username
result = run_json(cli.accounts.unfollow, friend.username, "--json")
relationship = from_dict(Relationship, result)
assert relationship.following is False
result = run_json(cli.accounts.following, user.username, "--json")
assert result == []
result = run_json(cli.accounts.followers, friend.username, "--json")
assert result == []
def test_mute(app, user, friend, friend_id, run):
# Make sure we're not initially muting friend
api.unmute(app, user, friend_id)
result = run(cli.accounts.muted)
assert result.exit_code == 0
out = result.stdout.strip()
assert out == "No accounts muted"
result = run(cli.accounts.mute, friend.username)
assert result.exit_code == 0
out = result.stdout.strip()
assert out == f"✓ You have muted {friend.username}"
result = run(cli.accounts.muted)
assert result.exit_code == 0
out = result.stdout.strip()
assert friend.username in out
result = run(cli.accounts.unmute, friend.username)
assert result.exit_code == 0
out = result.stdout.strip()
assert out == f"{friend.username} is no longer muted"
result = run(cli.accounts.muted)
assert result.exit_code == 0
out = result.stdout.strip()
assert out == "No accounts muted"
def test_mute_case_insensitive(friend: User, run):
result = run(cli.accounts.mute, friend.username.upper())
assert result.exit_code == 0
out = result.stdout.strip()
assert out == f"✓ You have muted {friend.username.upper()}"
def test_mute_not_found(run):
result = run(cli.accounts.mute, "doesnotexistperson")
assert result.exit_code == 1
assert result.stderr.strip() == "Error: Account not found"
result = run(cli.accounts.unmute, "doesnotexistperson")
assert result.exit_code == 1
assert result.stderr.strip() == "Error: Account not found"
def test_mute_json(app: App, user: User, friend: User, run_json, friend_id):
# Make sure we're not initially muting friend
api.unmute(app, user, friend_id)
result = run_json(cli.accounts.muted, "--json")
assert result == []
result = run_json(cli.accounts.mute, friend.username, "--json")
relationship = from_dict(Relationship, result)
assert relationship.id == friend_id
assert relationship.muting is True
[result] = run_json(cli.accounts.muted, "--json")
account = from_dict(Account, result)
assert account.id == friend_id
result = run_json(cli.accounts.unmute, friend.username, "--json")
relationship = from_dict(Relationship, result)
assert relationship.id == friend_id
assert relationship.muting is False
result = run_json(cli.accounts.muted, "--json")
assert result == []
def test_block(app, user, run):
friend = register_account(app)
result = run(cli.accounts.blocked)
assert result.exit_code == 0
out = result.stdout.strip()
assert out == "No accounts blocked"
result = run(cli.accounts.block, friend.username)
assert result.exit_code == 0
out = result.stdout.strip()
assert out == f"✓ You are now blocking {friend.username}"
result = run(cli.accounts.blocked)
assert result.exit_code == 0
out = result.stdout.strip()
assert friend.username in out
result = run(cli.accounts.unblock, friend.username)
assert result.exit_code == 0
out = result.stdout.strip()
assert out == f"{friend.username} is no longer blocked"
result = run(cli.accounts.blocked)
assert result.exit_code == 0
out = result.stdout.strip()
assert out == "No accounts blocked"
def test_block_case_insensitive(friend: User, run):
result = run(cli.accounts.block, friend.username.upper())
assert result.exit_code == 0
out = result.stdout.strip()
assert out == f"✓ You are now blocking {friend.username.upper()}"
def test_block_not_found(run):
result = run(cli.accounts.block, "doesnotexistperson")
assert result.exit_code == 1
assert result.stderr.strip() == "Error: Account not found"
def test_block_json(app: App, user: User, friend: User, run_json, friend_id):
# Make sure we're not initially blocking friend
api.unblock(app, user, friend_id)
result = run_json(cli.accounts.blocked, "--json")
assert result == []
result = run_json(cli.accounts.block, friend.username, "--json")
relationship = from_dict(Relationship, result)
assert relationship.id == friend_id
assert relationship.blocking is True
[result] = run_json(cli.accounts.blocked, "--json")
account = from_dict(Account, result)
assert account.id == friend_id
result = run_json(cli.accounts.unblock, friend.username, "--json")
relationship = from_dict(Relationship, result)
assert relationship.id == friend_id
assert relationship.blocking is False
result = run_json(cli.accounts.blocked, "--json")
assert result == []

Wyświetl plik

@ -1,121 +1,217 @@
from tests.integration.conftest import TRUMPET
from toot import api
from toot.utils import get_text
from typing import Any, Dict
from unittest import mock
from unittest.mock import MagicMock
from toot import User, cli
from tests.integration.conftest import PASSWORD, Run
# TODO: figure out how to test login
def test_update_account_no_options(run):
out = run("update_account")
assert out == "Please specify at least one option to update the account"
EMPTY_CONFIG: Dict[Any, Any] = {
"apps": {},
"users": {},
"active_user": None
}
SAMPLE_CONFIG = {
"active_user": "frank@foo.social",
"apps": {
"foo.social": {
"base_url": "http://foo.social",
"client_id": "123",
"client_secret": "123",
"instance": "foo.social"
},
"bar.social": {
"base_url": "http://bar.social",
"client_id": "123",
"client_secret": "123",
"instance": "bar.social"
},
},
"users": {
"frank@foo.social": {
"access_token": "123",
"instance": "foo.social",
"username": "frank"
},
"frank@bar.social": {
"access_token": "123",
"instance": "bar.social",
"username": "frank"
},
}
}
def test_update_account_display_name(run, app, user):
out = run("update_account", "--display-name", "elwood")
assert out == "✓ Account updated"
account = api.verify_credentials(app, user)
assert account["display_name"] == "elwood"
def test_env(run: Run):
result = run(cli.auth.env)
assert result.exit_code == 0
assert "toot" in result.stdout
assert "Python" in result.stdout
def test_update_account_note(run, app, user):
note = ("It's 106 miles to Chicago, we got a full tank of gas, half a pack "
"of cigarettes, it's dark... and we're wearing sunglasses.")
out = run("update_account", "--note", note)
assert out == "✓ Account updated"
account = api.verify_credentials(app, user)
assert get_text(account["note"]) == note
@mock.patch("toot.config.load_config")
def test_auth_empty(load_config: MagicMock, run: Run):
load_config.return_value = EMPTY_CONFIG
result = run(cli.auth.auth)
assert result.exit_code == 0
assert result.stdout.strip() == "You are not logged in to any accounts"
def test_update_account_language(run, app, user):
out = run("update_account", "--language", "hr")
assert out == "✓ Account updated"
account = api.verify_credentials(app, user)
assert account["source"]["language"] == "hr"
@mock.patch("toot.config.load_config")
def test_auth_full(load_config: MagicMock, run: Run):
load_config.return_value = SAMPLE_CONFIG
result = run(cli.auth.auth)
assert result.exit_code == 0
assert result.stdout.strip().startswith("Authenticated accounts:")
assert "frank@foo.social" in result.stdout
assert "frank@bar.social" in result.stdout
def test_update_account_privacy(run, app, user):
out = run("update_account", "--privacy", "private")
assert out == "✓ Account updated"
# Saving config is mocked so we don't mess up our local config
# TODO: could this be implemented using an auto-use fixture so we have it always
# mocked?
@mock.patch("toot.config.load_app")
@mock.patch("toot.config.save_app")
@mock.patch("toot.config.save_user")
def test_login_cli(
save_user: MagicMock,
save_app: MagicMock,
load_app: MagicMock,
user: User,
run: Run,
):
load_app.return_value = None
account = api.verify_credentials(app, user)
assert account["source"]["privacy"] == "private"
result = run(
cli.auth.login_cli,
"--instance", "http://localhost:3000",
"--email", f"{user.username}@example.com",
"--password", PASSWORD,
)
assert result.exit_code == 0
assert "✓ Successfully logged in." in result.stdout
save_app.assert_called_once()
(app,) = save_app.call_args.args
assert app.instance == "localhost:3000"
assert app.base_url == "http://localhost:3000"
assert app.client_id
assert app.client_secret
save_user.assert_called_once()
(new_user,) = save_user.call_args.args
assert new_user.instance == "localhost:3000"
assert new_user.username == user.username
# access token will be different since this is a new login
assert new_user.access_token and new_user.access_token != user.access_token
assert save_user.call_args.kwargs == {"activate": True}
def test_update_account_avatar(run, app, user):
account = api.verify_credentials(app, user)
old_value = account["avatar"]
@mock.patch("toot.config.load_app")
@mock.patch("toot.config.save_app")
@mock.patch("toot.config.save_user")
def test_login_cli_wrong_password(
save_user: MagicMock,
save_app: MagicMock,
load_app: MagicMock,
user: User,
run: Run,
):
load_app.return_value = None
out = run("update_account", "--avatar", TRUMPET)
assert out == "✓ Account updated"
result = run(
cli.auth.login_cli,
"--instance", "http://localhost:3000",
"--email", f"{user.username}@example.com",
"--password", "wrong password",
)
assert result.exit_code == 1
assert result.stderr.strip() == "Error: Login failed"
account = api.verify_credentials(app, user)
assert account["avatar"] != old_value
save_app.assert_called_once()
(app,) = save_app.call_args.args
assert app.instance == "localhost:3000"
assert app.base_url == "http://localhost:3000"
assert app.client_id
assert app.client_secret
save_user.assert_not_called()
def test_update_account_header(run, app, user):
account = api.verify_credentials(app, user)
old_value = account["header"]
@mock.patch("toot.config.load_config")
@mock.patch("toot.config.delete_user")
def test_logout(delete_user: MagicMock, load_config: MagicMock, run: Run):
load_config.return_value = SAMPLE_CONFIG
out = run("update_account", "--header", TRUMPET)
assert out == "✓ Account updated"
account = api.verify_credentials(app, user)
assert account["header"] != old_value
result = run(cli.auth.logout, "frank@foo.social")
assert result.exit_code == 0
assert result.stdout.strip() == "✓ Account frank@foo.social logged out"
delete_user.assert_called_once_with(User("foo.social", "frank", "123"))
def test_update_account_locked(run, app, user):
out = run("update_account", "--locked")
assert out == "✓ Account updated"
@mock.patch("toot.config.load_config")
def test_logout_not_logged_in(load_config: MagicMock, run: Run):
load_config.return_value = EMPTY_CONFIG
account = api.verify_credentials(app, user)
assert account["locked"] is True
out = run("update_account", "--no-locked")
assert out == "✓ Account updated"
account = api.verify_credentials(app, user)
assert account["locked"] is False
result = run(cli.auth.logout)
assert result.exit_code == 1
assert result.stderr.strip() == "Error: You're not logged into any accounts"
def test_update_account_bot(run, app, user):
out = run("update_account", "--bot")
assert out == "✓ Account updated"
@mock.patch("toot.config.load_config")
def test_logout_account_not_specified(load_config: MagicMock, run: Run):
load_config.return_value = SAMPLE_CONFIG
account = api.verify_credentials(app, user)
assert account["bot"] is True
out = run("update_account", "--no-bot")
assert out == "✓ Account updated"
account = api.verify_credentials(app, user)
assert account["bot"] is False
result = run(cli.auth.logout)
assert result.exit_code == 1
assert result.stderr.startswith("Error: Specify account to log out")
def test_update_account_discoverable(run, app, user):
out = run("update_account", "--discoverable")
assert out == "✓ Account updated"
@mock.patch("toot.config.load_config")
def test_logout_account_does_not_exist(load_config: MagicMock, run: Run):
load_config.return_value = SAMPLE_CONFIG
account = api.verify_credentials(app, user)
assert account["discoverable"] is True
out = run("update_account", "--no-discoverable")
assert out == "✓ Account updated"
account = api.verify_credentials(app, user)
assert account["discoverable"] is False
result = run(cli.auth.logout, "banana")
assert result.exit_code == 1
assert result.stderr.startswith("Error: Account not found")
def test_update_account_sensitive(run, app, user):
out = run("update_account", "--sensitive")
assert out == "✓ Account updated"
@mock.patch("toot.config.load_config")
@mock.patch("toot.config.activate_user")
def test_activate(activate_user: MagicMock, load_config: MagicMock, run: Run):
load_config.return_value = SAMPLE_CONFIG
account = api.verify_credentials(app, user)
assert account["source"]["sensitive"] is True
result = run(cli.auth.activate, "frank@foo.social")
assert result.exit_code == 0
assert result.stdout.strip() == "✓ Account frank@foo.social activated"
activate_user.assert_called_once_with(User("foo.social", "frank", "123"))
out = run("update_account", "--no-sensitive")
assert out == "✓ Account updated"
account = api.verify_credentials(app, user)
assert account["source"]["sensitive"] is False
@mock.patch("toot.config.load_config")
def test_activate_not_logged_in(load_config: MagicMock, run: Run):
load_config.return_value = EMPTY_CONFIG
result = run(cli.auth.activate)
assert result.exit_code == 1
assert result.stderr.strip() == "Error: You're not logged into any accounts"
@mock.patch("toot.config.load_config")
def test_activate_account_not_given(load_config: MagicMock, run: Run):
load_config.return_value = SAMPLE_CONFIG
result = run(cli.auth.activate)
assert result.exit_code == 1
assert result.stderr.startswith("Error: Specify account to activate")
@mock.patch("toot.config.load_config")
def test_activate_invalid_Account(load_config: MagicMock, run: Run):
load_config.return_value = SAMPLE_CONFIG
result = run(cli.auth.activate, "banana")
assert result.exit_code == 1
assert result.stderr.startswith("Error: Account not found")

Wyświetl plik

@ -1,67 +1,162 @@
from uuid import uuid4
from toot import cli
from tests.integration.conftest import register_account
def test_lists_empty(run):
out = run("lists")
assert out == "You have no lists defined."
result = run(cli.lists.list)
assert result.exit_code == 0
assert result.stdout.strip() == "You have no lists defined."
def test_lists_empty_json(run_json):
lists = run_json(cli.lists.list, "--json")
assert lists == []
def test_list_create_delete(run):
out = run("list_create", "banana")
assert out == '✓ List "banana" created.'
result = run(cli.lists.create, "banana")
assert result.exit_code == 0
assert result.stdout.strip() == '✓ List "banana" created.'
out = run("lists")
assert "banana" in out
result = run(cli.lists.list)
assert result.exit_code == 0
assert "banana" in result.stdout
out = run("list_create", "mango")
assert out == '✓ List "mango" created.'
result = run(cli.lists.create, "mango")
assert result.exit_code == 0
assert result.stdout.strip() == '✓ List "mango" created.'
out = run("lists")
assert "banana" in out
assert "mango" in out
result = run(cli.lists.list)
assert result.exit_code == 0
assert "banana" in result.stdout
assert "mango" in result.stdout
out = run("list_delete", "banana")
assert out == '✓ List "banana" deleted.'
result = run(cli.lists.delete, "banana")
assert result.exit_code == 0
assert result.stdout.strip() == '✓ List "banana" deleted.'
out = run("lists")
assert "banana" not in out
assert "mango" in out
result = run(cli.lists.list)
assert result.exit_code == 0
assert "banana" not in result.stdout
assert "mango" in result.stdout
out = run("list_delete", "mango")
assert out == '✓ List "mango" deleted.'
result = run(cli.lists.delete, "mango")
assert result.exit_code == 0
assert result.stdout.strip() == '✓ List "mango" deleted.'
out = run("lists")
assert out == "You have no lists defined."
result = run(cli.lists.list)
assert result.exit_code == 0
assert result.stdout.strip() == "You have no lists defined."
out = run("list_delete", "mango")
assert out == "List not found"
result = run(cli.lists.delete, "mango")
assert result.exit_code == 1
assert result.stderr.strip() == "Error: List not found"
def test_list_create_delete_json(run, run_json):
result = run_json(cli.lists.list, "--json")
assert result == []
list = run_json(cli.lists.create, "banana", "--json")
assert list["title"] == "banana"
[list] = run_json(cli.lists.list, "--json")
assert list["title"] == "banana"
list = run_json(cli.lists.create, "mango", "--json")
assert list["title"] == "mango"
lists = run_json(cli.lists.list, "--json")
[list1, list2] = sorted(lists, key=lambda l: l["title"])
assert list1["title"] == "banana"
assert list2["title"] == "mango"
result = run_json(cli.lists.delete, "banana", "--json")
assert result == {}
[list] = run_json(cli.lists.list, "--json")
assert list["title"] == "mango"
result = run_json(cli.lists.delete, "mango", "--json")
assert result == {}
result = run_json(cli.lists.list, "--json")
assert result == []
result = run(cli.lists.delete, "mango", "--json")
assert result.exit_code == 1
assert result.stderr.strip() == "Error: List not found"
def test_list_add_remove(run, app):
list_name = str(uuid4())
acc = register_account(app)
run("list_create", "foo")
run(cli.lists.create, list_name)
out = run("list_add", "foo", acc.username)
assert out == f"You must follow @{acc.username} before adding this account to a list."
result = run(cli.lists.add, list_name, acc.username)
assert result.exit_code == 1
assert result.stderr.strip() == f"Error: You must follow @{acc.username} before adding this account to a list."
run("follow", acc.username)
run(cli.accounts.follow, acc.username)
out = run("list_add", "foo", acc.username)
assert out == f'✓ Added account "{acc.username}"'
result = run(cli.lists.add, list_name, acc.username)
assert result.exit_code == 0
assert result.stdout.strip() == f'✓ Added account "{acc.username}"'
out = run("list_accounts", "foo")
assert acc.username in out
result = run(cli.lists.accounts, list_name)
assert result.exit_code == 0
assert acc.username in result.stdout
# Account doesn't exist
out = run("list_add", "foo", "does_not_exist")
assert out == "Account not found"
result = run(cli.lists.add, list_name, "does_not_exist")
assert result.exit_code == 1
assert result.stderr.strip() == "Error: Account not found"
# List doesn't exist
out = run("list_add", "does_not_exist", acc.username)
assert out == "List not found"
result = run(cli.lists.add, "does_not_exist", acc.username)
assert result.exit_code == 1
assert result.stderr.strip() == "Error: List not found"
out = run("list_remove", "foo", acc.username)
assert out == f'✓ Removed account "{acc.username}"'
result = run(cli.lists.remove, list_name, acc.username)
assert result.exit_code == 0
assert result.stdout.strip() == f'✓ Removed account "{acc.username}"'
out = run("list_accounts", "foo")
assert out == "This list has no accounts."
result = run(cli.lists.accounts, list_name)
assert result.exit_code == 0
assert result.stdout.strip() == "This list has no accounts."
def test_list_add_remove_json(run, run_json, app):
list_name = str(uuid4())
acc = register_account(app)
run(cli.lists.create, list_name)
result = run(cli.lists.add, list_name, acc.username, "--json")
assert result.exit_code == 1
assert result.stderr.strip() == f"Error: You must follow @{acc.username} before adding this account to a list."
run(cli.accounts.follow, acc.username)
result = run_json(cli.lists.add, list_name, acc.username, "--json")
assert result == {}
[account] = run_json(cli.lists.accounts, list_name, "--json")
assert account["username"] == acc.username
# Account doesn't exist
result = run(cli.lists.add, list_name, "does_not_exist", "--json")
assert result.exit_code == 1
assert result.stderr.strip() == "Error: Account not found"
# List doesn't exist
result = run(cli.lists.add, "does_not_exist", acc.username, "--json")
assert result.exit_code == 1
assert result.stderr.strip() == "Error: List not found"
result = run_json(cli.lists.remove, list_name, acc.username, "--json")
assert result == {}
result = run_json(cli.lists.accounts, list_name, "--json")
assert result == []

Wyświetl plik

@ -1,20 +1,23 @@
import json
import re
import uuid
from datetime import datetime, timedelta, timezone
from os import path
from tests.integration.conftest import ASSETS_DIR, posted_status_id
from toot import CLIENT_NAME, CLIENT_WEBSITE, api
from toot import CLIENT_NAME, CLIENT_WEBSITE, api, cli
from toot.utils import get_text
from unittest import mock
def test_post(app, user, run):
text = "i wish i was a #lumberjack"
out = run("post", text)
status_id = posted_status_id(out)
result = run(cli.post.post, text)
assert result.exit_code == 0
status = api.fetch_status(app, user, status_id)
status_id = posted_status_id(result.stdout)
status = api.fetch_status(app, user, status_id).json()
assert text == get_text(status["content"])
assert status["visibility"] == "public"
assert status["sensitive"] is False
@ -27,11 +30,32 @@ def test_post(app, user, run):
assert status["application"]["website"] == CLIENT_WEBSITE
def test_post_no_text(run):
result = run(cli.post.post)
assert result.exit_code == 1
assert result.stderr.strip() == "Error: You must specify either text or media to post."
def test_post_json(run):
content = "i wish i was a #lumberjack"
result = run(cli.post.post, content, "--json")
assert result.exit_code == 0
status = json.loads(result.stdout)
assert get_text(status["content"]) == content
assert status["visibility"] == "public"
assert status["sensitive"] is False
assert status["spoiler_text"] == ""
assert status["poll"] is None
def test_post_visibility(app, user, run):
for visibility in ["public", "unlisted", "private", "direct"]:
out = run("post", "foo", "--visibility", visibility)
status_id = posted_status_id(out)
status = api.fetch_status(app, user, status_id)
result = run(cli.post.post, "foo", "--visibility", visibility)
assert result.exit_code == 0
status_id = posted_status_id(result.stdout)
status = api.fetch_status(app, user, status_id).json()
assert status["visibility"] == visibility
@ -39,14 +63,23 @@ def test_post_scheduled_at(app, user, run):
text = str(uuid.uuid4())
scheduled_at = datetime.now(timezone.utc).replace(microsecond=0) + timedelta(minutes=10)
out = run("post", text, "--scheduled-at", scheduled_at.isoformat())
assert "Toot scheduled for" in out
result = run(cli.post.post, text, "--scheduled-at", scheduled_at.isoformat())
assert result.exit_code == 0
assert "Toot scheduled for" in result.stdout
statuses = api.scheduled_statuses(app, user)
[status] = [s for s in statuses if s["params"]["text"] == text]
assert datetime.strptime(status["scheduled_at"], "%Y-%m-%dT%H:%M:%S.%f%z") == scheduled_at
def test_post_scheduled_at_error(run):
result = run(cli.post.post, "foo", "--scheduled-at", "banana")
assert result.exit_code == 1
# Stupid error returned by mastodon
assert result.stderr.strip() == "Error: Record invalid"
def test_post_scheduled_in(app, user, run):
text = str(uuid.uuid4())
@ -63,9 +96,11 @@ def test_post_scheduled_in(app, user, run):
datetimes = []
for scheduled_in, delta in variants:
out = run("post", text, "--scheduled-in", scheduled_in)
result = run(cli.post.post, text, "--scheduled-in", scheduled_in)
assert result.exit_code == 0
dttm = datetime.utcnow() + delta
assert out.startswith(f"Toot scheduled for: {str(dttm)[:16]}")
assert result.stdout.startswith(f"Toot scheduled for: {str(dttm)[:16]}")
datetimes.append(dttm)
scheduled = api.scheduled_statuses(app, user)
@ -79,20 +114,33 @@ def test_post_scheduled_in(app, user, run):
assert delta.total_seconds() < 5
def test_post_scheduled_in_invalid_duration(run):
result = run(cli.post.post, "foo", "--scheduled-in", "banana")
assert result.exit_code == 2
assert "Invalid duration: banana" in result.stderr
def test_post_scheduled_in_empty_duration(run):
result = run(cli.post.post, "foo", "--scheduled-in", "0m")
assert result.exit_code == 2
assert "Empty duration" in result.stderr
def test_post_poll(app, user, run):
text = str(uuid.uuid4())
out = run(
"post", text,
result = run(
cli.post.post, text,
"--poll-option", "foo",
"--poll-option", "bar",
"--poll-option", "baz",
"--poll-option", "qux",
)
status_id = posted_status_id(out)
assert result.exit_code == 0
status_id = posted_status_id(result.stdout)
status = api.fetch_status(app, user, status_id)
status = api.fetch_status(app, user, status_id).json()
assert status["poll"]["expired"] is False
assert status["poll"]["multiple"] is False
assert status["poll"]["options"] == [
@ -112,32 +160,33 @@ def test_post_poll(app, user, run):
def test_post_poll_multiple(app, user, run):
text = str(uuid.uuid4())
out = run(
"post", text,
result = run(
cli.post.post, text,
"--poll-option", "foo",
"--poll-option", "bar",
"--poll-multiple"
)
assert result.exit_code == 0
status_id = posted_status_id(out)
status = api.fetch_status(app, user, status_id)
status_id = posted_status_id(result.stdout)
status = api.fetch_status(app, user, status_id).json()
assert status["poll"]["multiple"] is True
def test_post_poll_expires_in(app, user, run):
text = str(uuid.uuid4())
out = run(
"post", text,
result = run(
cli.post.post, text,
"--poll-option", "foo",
"--poll-option", "bar",
"--poll-expires-in", "8h",
)
assert result.exit_code == 0
status_id = posted_status_id(out)
status_id = posted_status_id(result.stdout)
status = api.fetch_status(app, user, status_id)
status = api.fetch_status(app, user, status_id).json()
actual = datetime.strptime(status["poll"]["expires_at"], "%Y-%m-%dT%H:%M:%S.%f%z")
expected = datetime.now(timezone.utc) + timedelta(hours=8)
delta = actual - expected
@ -147,16 +196,17 @@ def test_post_poll_expires_in(app, user, run):
def test_post_poll_hide_totals(app, user, run):
text = str(uuid.uuid4())
out = run(
"post", text,
result = run(
cli.post.post, text,
"--poll-option", "foo",
"--poll-option", "bar",
"--poll-hide-totals"
)
assert result.exit_code == 0
status_id = posted_status_id(out)
status_id = posted_status_id(result.stdout)
status = api.fetch_status(app, user, status_id)
status = api.fetch_status(app, user, status_id).json()
# votes_count is None when totals are hidden
assert status["poll"]["options"] == [
@ -166,31 +216,42 @@ def test_post_poll_hide_totals(app, user, run):
def test_post_language(app, user, run):
out = run("post", "test", "--language", "hr")
status_id = posted_status_id(out)
status = api.fetch_status(app, user, status_id)
result = run(cli.post.post, "test", "--language", "hr")
assert result.exit_code == 0
status_id = posted_status_id(result.stdout)
status = api.fetch_status(app, user, status_id).json()
assert status["language"] == "hr"
out = run("post", "test", "--language", "zh")
status_id = posted_status_id(out)
status = api.fetch_status(app, user, status_id)
result = run(cli.post.post, "test", "--language", "zh")
assert result.exit_code == 0
status_id = posted_status_id(result.stdout)
status = api.fetch_status(app, user, status_id).json()
assert status["language"] == "zh"
def test_post_language_error(run):
result = run(cli.post.post, "test", "--language", "banana")
assert result.exit_code == 2
assert "Language should be a two letter abbreviation." in result.stderr
def test_media_thumbnail(app, user, run):
video_path = path.join(ASSETS_DIR, "small.webm")
thumbnail_path = path.join(ASSETS_DIR, "test1.png")
out = run(
"post",
result = run(
cli.post.post,
"--media", video_path,
"--thumbnail", thumbnail_path,
"--description", "foo",
"some text"
)
assert result.exit_code == 0
status_id = posted_status_id(out)
status = api.fetch_status(app, user, status_id)
status_id = posted_status_id(result.stdout)
status = api.fetch_status(app, user, status_id).json()
[media] = status["media_attachments"]
assert media["description"] == "foo"
@ -214,8 +275,8 @@ def test_media_attachments(app, user, run):
path3 = path.join(ASSETS_DIR, "test3.png")
path4 = path.join(ASSETS_DIR, "test4.png")
out = run(
"post",
result = run(
cli.post.post,
"--media", path1,
"--media", path2,
"--media", path3,
@ -226,9 +287,10 @@ def test_media_attachments(app, user, run):
"--description", "Test 4",
"some text"
)
assert result.exit_code == 0
status_id = posted_status_id(out)
status = api.fetch_status(app, user, status_id)
status_id = posted_status_id(result.stdout)
status = api.fetch_status(app, user, status_id).json()
[a1, a2, a3, a4] = status["media_attachments"]
@ -245,6 +307,13 @@ def test_media_attachments(app, user, run):
assert a4["description"] == "Test 4"
def test_too_many_media(run):
m = path.join(ASSETS_DIR, "test1.png")
result = run(cli.post.post, "-m", m, "-m", m, "-m", m, "-m", m, "-m", m)
assert result.exit_code == 1
assert result.stderr.strip() == "Error: Cannot attach more than 4 files."
@mock.patch("toot.utils.multiline_input")
@mock.patch("sys.stdin.read")
def test_media_attachment_without_text(mock_read, mock_ml, app, user, run):
@ -254,10 +323,12 @@ def test_media_attachment_without_text(mock_read, mock_ml, app, user, run):
media_path = path.join(ASSETS_DIR, "test1.png")
out = run("post", "--media", media_path)
status_id = posted_status_id(out)
result = run(cli.post.post, "--media", media_path)
assert result.exit_code == 0
status = api.fetch_status(app, user, status_id)
status_id = posted_status_id(result.stdout)
status = api.fetch_status(app, user, status_id).json()
assert status["content"] == ""
[attachment] = status["media_attachments"]
@ -269,16 +340,20 @@ def test_media_attachment_without_text(mock_read, mock_ml, app, user, run):
def test_reply_thread(app, user, friend, run):
status = api.post_status(app, friend, "This is the status")
status = api.post_status(app, friend, "This is the status").json()
out = run("post", "--reply-to", status["id"], "This is the reply")
status_id = posted_status_id(out)
reply = api.fetch_status(app, user, status_id)
result = run(cli.post.post, "--reply-to", status["id"], "This is the reply")
assert result.exit_code == 0
status_id = posted_status_id(result.stdout)
reply = api.fetch_status(app, user, status_id).json()
assert reply["in_reply_to_id"] == status["id"]
out = run("thread", status["id"])
[s1, s2] = [s.strip() for s in re.split(r"─+", out) if s.strip()]
result = run(cli.read.thread, status["id"])
assert result.exit_code == 0
[s1, s2] = [s.strip() for s in re.split(r"─+", result.stdout) if s.strip()]
assert "This is the status" in s1
assert "This is the reply" in s2

Wyświetl plik

@ -1,34 +1,68 @@
import json
import re
from tests.integration.conftest import TOOT_TEST_BASE_URL
from toot import api, cli
from toot.entities import Account, Status, from_dict, from_dict_list
from uuid import uuid4
import pytest
from toot import api
from toot.exceptions import ConsoleError
def test_instance(app, run):
out = run("instance", "--disable-https")
assert "Mastodon" in out
assert app.instance in out
assert "running Mastodon" in out
def test_instance_default(app, run):
result = run(cli.read.instance)
assert result.exit_code == 0
assert "Mastodon" in result.stdout
assert app.instance in result.stdout
assert "running Mastodon" in result.stdout
def test_instance_with_url(app, run):
result = run(cli.read.instance, TOOT_TEST_BASE_URL)
assert result.exit_code == 0
assert "Mastodon" in result.stdout
assert app.instance in result.stdout
assert "running Mastodon" in result.stdout
def test_instance_json(app, run):
result = run(cli.read.instance, "--json")
assert result.exit_code == 0
data = json.loads(result.stdout)
assert data["title"] is not None
assert data["description"] is not None
assert data["version"] is not None
def test_instance_anon(app, run_anon, base_url):
out = run_anon("instance", base_url)
assert "Mastodon" in out
assert app.instance in out
assert "running Mastodon" in out
result = run_anon(cli.read.instance, base_url)
assert result.exit_code == 0
assert "Mastodon" in result.stdout
assert app.instance in result.stdout
assert "running Mastodon" in result.stdout
# Need to specify the instance name when running anon
with pytest.raises(ConsoleError) as exc:
run_anon("instance")
assert str(exc.value) == "Please specify an instance."
result = run_anon(cli.read.instance)
assert result.exit_code == 1
assert result.stderr.strip() == "Error: INSTANCE argument not given and not logged in"
def test_whoami(user, run):
out = run("whoami")
# TODO: test other fields once updating account is supported
assert f"@{user.username}" in out
result = run(cli.read.whoami)
assert result.exit_code == 0
assert f"@{user.username}" in result.stdout
def test_whoami_json(user, run):
result = run(cli.read.whoami, "--json")
assert result.exit_code == 0
data = json.loads(result.stdout)
account = from_dict(Account, data)
assert account.username == user.username
assert account.acct == user.username
def test_whois(app, friend, run):
@ -40,13 +74,34 @@ def test_whois(app, friend, run):
]
for username in variants:
out = run("whois", username)
assert f"@{friend.username}" in out
result = run(cli.read.whois, username)
assert result.exit_code == 0
assert f"@{friend.username}" in result.stdout
def test_whois_json(app, friend, run):
result = run(cli.read.whois, friend.username, "--json")
assert result.exit_code == 0
data = json.loads(result.stdout)
account = from_dict(Account, data)
assert account.username == friend.username
assert account.acct == friend.username
def test_search_account(friend, run):
out = run("search", friend.username)
assert out == f"Accounts:\n* @{friend.username}"
result = run(cli.read.search, friend.username)
assert result.exit_code == 0
assert result.stdout.strip() == f"Accounts:\n* @{friend.username}"
def test_search_account_json(friend, run):
result = run(cli.read.search, friend.username, "--json")
assert result.exit_code == 0
data = json.loads(result.stdout)
[account] = from_dict_list(Account, data["accounts"])
assert account.acct == friend.username
def test_search_hashtag(app, user, run):
@ -54,55 +109,67 @@ def test_search_hashtag(app, user, run):
api.post_status(app, user, "#hashtag_y")
api.post_status(app, user, "#hashtag_z")
out = run("search", "#hashtag")
assert out == "Hashtags:\n#hashtag_x, #hashtag_y, #hashtag_z"
result = run(cli.read.search, "#hashtag")
assert result.exit_code == 0
assert result.stdout.strip() == "Hashtags:\n#hashtag_x, #hashtag_y, #hashtag_z"
def test_tags(run, base_url):
out = run("tags_followed")
assert out == "You're not following any hashtags."
def test_search_hashtag_json(app, user, run):
api.post_status(app, user, "#hashtag_x")
api.post_status(app, user, "#hashtag_y")
api.post_status(app, user, "#hashtag_z")
out = run("tags_follow", "foo")
assert out == "✓ You are now following #foo"
result = run(cli.read.search, "#hashtag", "--json")
assert result.exit_code == 0
out = run("tags_followed")
assert out == f"* #foo\t{base_url}/tags/foo"
data = json.loads(result.stdout)
[h1, h2, h3] = sorted(data["hashtags"], key=lambda h: h["name"])
out = run("tags_follow", "bar")
assert out == "✓ You are now following #bar"
out = run("tags_followed")
assert out == "\n".join([
f"* #bar\t{base_url}/tags/bar",
f"* #foo\t{base_url}/tags/foo",
])
out = run("tags_unfollow", "foo")
assert out == "✓ You are no longer following #foo"
out = run("tags_followed")
assert out == f"* #bar\t{base_url}/tags/bar"
assert h1["name"] == "hashtag_x"
assert h2["name"] == "hashtag_y"
assert h3["name"] == "hashtag_z"
def test_status(app, user, run):
uuid = str(uuid4())
response = api.post_status(app, user, uuid)
status_id = api.post_status(app, user, uuid).json()["id"]
out = run("status", response["id"])
result = run(cli.read.status, status_id)
assert result.exit_code == 0
out = result.stdout.strip()
assert uuid in out
assert user.username in out
assert response["id"] in out
assert status_id in out
def test_status_json(app, user, run):
uuid = str(uuid4())
status_id = api.post_status(app, user, uuid).json()["id"]
result = run(cli.read.status, status_id, "--json")
assert result.exit_code == 0
status = from_dict(Status, json.loads(result.stdout))
assert status.id == status_id
assert status.account.acct == user.username
assert uuid in status.content
def test_thread(app, user, run):
uuid = str(uuid4())
s1 = api.post_status(app, user, uuid + "1")
s2 = api.post_status(app, user, uuid + "2", in_reply_to_id=s1["id"])
s3 = api.post_status(app, user, uuid + "3", in_reply_to_id=s2["id"])
uuid1 = str(uuid4())
uuid2 = str(uuid4())
uuid3 = str(uuid4())
s1 = api.post_status(app, user, uuid1).json()
s2 = api.post_status(app, user, uuid2, in_reply_to_id=s1["id"]).json()
s3 = api.post_status(app, user, uuid3, in_reply_to_id=s2["id"]).json()
for status in [s1, s2, s3]:
out = run("thread", status["id"])
bits = re.split(r"─+", out)
result = run(cli.read.thread, status["id"])
assert result.exit_code == 0
bits = re.split(r"─+", result.stdout.strip())
bits = [b for b in bits if b]
assert len(bits) == 3
@ -111,6 +178,26 @@ def test_thread(app, user, run):
assert s2["id"] in bits[1]
assert s3["id"] in bits[2]
assert f"{uuid}1" in bits[0]
assert f"{uuid}2" in bits[1]
assert f"{uuid}3" in bits[2]
assert uuid1 in bits[0]
assert uuid2 in bits[1]
assert uuid3 in bits[2]
def test_thread_json(app, user, run):
uuid1 = str(uuid4())
uuid2 = str(uuid4())
uuid3 = str(uuid4())
s1 = api.post_status(app, user, uuid1).json()
s2 = api.post_status(app, user, uuid2, in_reply_to_id=s1["id"]).json()
s3 = api.post_status(app, user, uuid3, in_reply_to_id=s2["id"]).json()
result = run(cli.read.thread, s2["id"], "--json")
assert result.exit_code == 0
result = json.loads(result.stdout)
[ancestor] = [from_dict(Status, s) for s in result["ancestors"]]
[descendent] = [from_dict(Status, s) for s in result["descendants"]]
assert ancestor.id == s1["id"]
assert descendent.id == s3["id"]

Wyświetl plik

@ -1,89 +1,200 @@
import time
import json
import pytest
from toot import api
from tests.utils import run_with_retries
from toot import api, cli
from toot.exceptions import NotFoundError
def test_delete_status(app, user, run):
status = api.post_status(app, user, "foo")
def test_delete(app, user, run):
status = api.post_status(app, user, "foo").json()
out = run("delete", status["id"])
assert out == "✓ Status deleted"
result = run(cli.statuses.delete, status["id"])
assert result.exit_code == 0
assert result.stdout.strip() == "✓ Status deleted"
with pytest.raises(NotFoundError):
api.fetch_status(app, user, status["id"])
def test_delete_json(app, user, run):
status = api.post_status(app, user, "foo").json()
result = run(cli.statuses.delete, status["id"], "--json")
assert result.exit_code == 0
out = result.stdout
result = json.loads(out)
assert result["id"] == status["id"]
with pytest.raises(NotFoundError):
api.fetch_status(app, user, status["id"])
def test_favourite(app, user, run):
status = api.post_status(app, user, "foo")
status = api.post_status(app, user, "foo").json()
assert not status["favourited"]
out = run("favourite", status["id"])
assert out == "✓ Status favourited"
result = run(cli.statuses.favourite, status["id"])
assert result.exit_code == 0
assert result.stdout.strip() == "✓ Status favourited"
status = api.fetch_status(app, user, status["id"])
status = api.fetch_status(app, user, status["id"]).json()
assert status["favourited"]
out = run("unfavourite", status["id"])
assert out == "✓ Status unfavourited"
result = run(cli.statuses.unfavourite, status["id"])
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.1)
def test_favourited():
nonlocal status
status = api.fetch_status(app, user, status["id"]).json()
assert not status["favourited"]
run_with_retries(test_favourited)
status = api.fetch_status(app, user, status["id"])
def test_favourite_json(app, user, run):
status = api.post_status(app, user, "foo").json()
assert not status["favourited"]
result = run(cli.statuses.favourite, status["id"], "--json")
assert result.exit_code == 0
result = json.loads(result.stdout)
assert result["id"] == status["id"]
assert result["favourited"] is True
result = run(cli.statuses.unfavourite, status["id"], "--json")
assert result.exit_code == 0
result = json.loads(result.stdout)
assert result["id"] == status["id"]
assert result["favourited"] is False
def test_reblog(app, user, run):
status = api.post_status(app, user, "foo")
status = api.post_status(app, user, "foo").json()
assert not status["reblogged"]
out = run("reblog", status["id"])
assert out == "✓ Status reblogged"
result = run(cli.statuses.reblogged_by, status["id"])
assert result.exit_code == 0
assert result.stdout.strip() == "This status is not reblogged by anyone"
status = api.fetch_status(app, user, status["id"])
result = run(cli.statuses.reblog, status["id"])
assert result.exit_code == 0
assert result.stdout.strip() == "✓ Status reblogged"
status = api.fetch_status(app, user, status["id"]).json()
assert status["reblogged"]
out = run("reblogged_by", status["id"])
assert user.username in out
result = run(cli.statuses.reblogged_by, status["id"])
assert result.exit_code == 0
assert user.username in result.stdout
out = run("unreblog", status["id"])
assert out == "✓ Status unreblogged"
result = run(cli.statuses.unreblog, status["id"])
assert result.exit_code == 0
assert result.stdout.strip() == "✓ Status unreblogged"
status = api.fetch_status(app, user, status["id"])
status = api.fetch_status(app, user, status["id"]).json()
assert not status["reblogged"]
def test_reblog_json(app, user, run):
status = api.post_status(app, user, "foo").json()
assert not status["reblogged"]
result = run(cli.statuses.reblog, status["id"], "--json")
assert result.exit_code == 0
result = json.loads(result.stdout)
assert result["reblogged"] is True
assert result["reblog"]["id"] == status["id"]
result = run(cli.statuses.reblogged_by, status["id"], "--json")
assert result.exit_code == 0
[reblog] = json.loads(result.stdout)
assert reblog["acct"] == user.username
result = run(cli.statuses.unreblog, status["id"], "--json")
assert result.exit_code == 0
result = json.loads(result.stdout)
assert result["reblogged"] is False
assert result["reblog"] is None
def test_pin(app, user, run):
status = api.post_status(app, user, "foo")
status = api.post_status(app, user, "foo").json()
assert not status["pinned"]
out = run("pin", status["id"])
assert out == "✓ Status pinned"
result = run(cli.statuses.pin, status["id"])
assert result.exit_code == 0
assert result.stdout.strip() == "✓ Status pinned"
status = api.fetch_status(app, user, status["id"])
status = api.fetch_status(app, user, status["id"]).json()
assert status["pinned"]
out = run("unpin", status["id"])
assert out == "✓ Status unpinned"
result = run(cli.statuses.unpin, status["id"])
assert result.exit_code == 0
assert result.stdout.strip() == "✓ Status unpinned"
status = api.fetch_status(app, user, status["id"])
status = api.fetch_status(app, user, status["id"]).json()
assert not status["pinned"]
def test_pin_json(app, user, run):
status = api.post_status(app, user, "foo").json()
assert not status["pinned"]
result = run(cli.statuses.pin, status["id"], "--json")
assert result.exit_code == 0
result = json.loads(result.stdout)
assert result["pinned"] is True
assert result["id"] == status["id"]
result = run(cli.statuses.unpin, status["id"], "--json")
assert result.exit_code == 0
result = json.loads(result.stdout)
assert result["pinned"] is False
assert result["id"] == status["id"]
def test_bookmark(app, user, run):
status = api.post_status(app, user, "foo")
status = api.post_status(app, user, "foo").json()
assert not status["bookmarked"]
out = run("bookmark", status["id"])
assert out == "✓ Status bookmarked"
result = run(cli.statuses.bookmark, status["id"])
assert result.exit_code == 0
assert result.stdout.strip() == "✓ Status bookmarked"
status = api.fetch_status(app, user, status["id"])
status = api.fetch_status(app, user, status["id"]).json()
assert status["bookmarked"]
out = run("unbookmark", status["id"])
assert out == "✓ Status unbookmarked"
result = run(cli.statuses.unbookmark, status["id"])
assert result.exit_code == 0
assert result.stdout.strip() == "✓ Status unbookmarked"
status = api.fetch_status(app, user, status["id"])
status = api.fetch_status(app, user, status["id"]).json()
assert not status["bookmarked"]
def test_bookmark_json(app, user, run):
status = api.post_status(app, user, "foo").json()
assert not status["bookmarked"]
result = run(cli.statuses.bookmark, status["id"], "--json")
assert result.exit_code == 0
result = json.loads(result.stdout)
assert result["id"] == status["id"]
assert result["bookmarked"] is True
result = run(cli.statuses.unbookmark, status["id"], "--json")
assert result.exit_code == 0
result = json.loads(result.stdout)
assert result["id"] == status["id"]
assert result["bookmarked"] is False

Wyświetl plik

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

Wyświetl plik

@ -0,0 +1,196 @@
import pytest
from uuid import uuid4
from tests.utils import run_with_retries
from toot import api, cli
from toot.entities import from_dict, Status
from tests.integration.conftest import TOOT_TEST_BASE_URL, register_account
# TODO: If fixture is not overridden here, tests fail, not sure why, figure it out
@pytest.fixture(scope="module")
def user(app):
return register_account(app)
@pytest.fixture(scope="module")
def other_user(app):
return register_account(app)
@pytest.fixture(scope="module")
def friend_user(app, user):
friend = register_account(app)
friend_account = api.find_account(app, user, friend.username)
api.follow(app, user, friend_account["id"])
return friend
@pytest.fixture(scope="module")
def friend_list(app, user, friend_user):
friend_account = api.find_account(app, user, friend_user.username)
list = api.create_list(app, user, str(uuid4())).json()
api.add_accounts_to_list(app, user, list["id"], account_ids=[friend_account["id"]])
return list
def test_timelines(app, user, other_user, friend_user, friend_list, run):
status1 = _post_status(app, user, "#foo")
status2 = _post_status(app, other_user, "#bar")
status3 = _post_status(app, friend_user, "#foo #bar")
# Home timeline
def test_home():
result = run(cli.timelines.timeline)
assert result.exit_code == 0
assert status1.id in result.stdout
assert status2.id not in result.stdout
assert status3.id in result.stdout
run_with_retries(test_home)
# Public timeline
result = run(cli.timelines.timeline, "--public")
assert result.exit_code == 0
assert status1.id in result.stdout
assert status2.id in result.stdout
assert status3.id in result.stdout
# Anon public timeline
result = run(cli.timelines.timeline, "--instance", TOOT_TEST_BASE_URL, "--public")
assert result.exit_code == 0
assert status1.id in result.stdout
assert status2.id in result.stdout
assert status3.id in result.stdout
# Tag timeline
result = run(cli.timelines.timeline, "--tag", "foo")
assert result.exit_code == 0
assert status1.id in result.stdout
assert status2.id not in result.stdout
assert status3.id in result.stdout
result = run(cli.timelines.timeline, "--tag", "bar")
assert result.exit_code == 0
assert status1.id not in result.stdout
assert status2.id in result.stdout
assert status3.id in result.stdout
# Anon tag timeline
result = run(cli.timelines.timeline, "--instance", TOOT_TEST_BASE_URL, "--tag", "foo")
assert result.exit_code == 0
assert status1.id in result.stdout
assert status2.id not in result.stdout
assert status3.id in result.stdout
# List timeline (by list name)
result = run(cli.timelines.timeline, "--list", friend_list["title"])
assert result.exit_code == 0
assert status1.id not in result.stdout
assert status2.id not in result.stdout
assert status3.id in result.stdout
# List timeline (by list ID)
result = run(cli.timelines.timeline, "--list", friend_list["id"])
assert result.exit_code == 0
assert status1.id not in result.stdout
assert status2.id not in result.stdout
assert status3.id in result.stdout
# Account timeline
result = run(cli.timelines.timeline, "--account", friend_user.username)
assert result.exit_code == 0
assert status1.id not in result.stdout
assert status2.id not in result.stdout
assert status3.id in result.stdout
result = run(cli.timelines.timeline, "--account", other_user.username)
assert result.exit_code == 0
assert status1.id not in result.stdout
assert status2.id in result.stdout
assert status3.id not in result.stdout
def test_empty_timeline(app, run_as):
user = register_account(app)
result = run_as(user, cli.timelines.timeline)
assert result.exit_code == 0
assert result.stdout.strip() == "" * 80
def test_timeline_cant_combine_timelines(run):
result = run(cli.timelines.timeline, "--tag", "foo", "--account", "bar")
assert result.exit_code == 1
assert result.stderr.strip() == "Error: Only one of --public, --tag, --account, or --list can be used at one time."
def test_timeline_local_needs_public_or_tag(run):
result = run(cli.timelines.timeline, "--local")
assert result.exit_code == 1
assert result.stderr.strip() == "Error: The --local option is only valid alongside --public or --tag."
def test_timeline_instance_needs_public_or_tag(run):
result = run(cli.timelines.timeline, "--instance", TOOT_TEST_BASE_URL)
assert result.exit_code == 1
assert result.stderr.strip() == "Error: The --instance option is only valid alongside --public or --tag."
def test_bookmarks(app, user, run):
status1 = _post_status(app, user)
status2 = _post_status(app, user)
api.bookmark(app, user, status1.id)
api.bookmark(app, user, status2.id)
result = run(cli.timelines.bookmarks)
assert result.exit_code == 0
assert status1.id in result.stdout
assert status2.id in result.stdout
assert result.stdout.find(status1.id) > result.stdout.find(status2.id)
result = run(cli.timelines.bookmarks, "--reverse")
assert result.exit_code == 0
assert status1.id in result.stdout
assert status2.id in result.stdout
assert result.stdout.find(status1.id) < result.stdout.find(status2.id)
def test_notifications(app, user, other_user, run):
result = run(cli.timelines.notifications)
assert result.exit_code == 0
assert result.stdout.strip() == "You have no notifications"
text = f"Paging doctor @{user.username}"
status = _post_status(app, other_user, text)
def test_notifications():
result = run(cli.timelines.notifications)
assert result.exit_code == 0
assert f"@{other_user.username} mentioned you" in result.stdout
assert status.id in result.stdout
assert text in result.stdout
run_with_retries(test_notifications)
result = run(cli.timelines.notifications, "--mentions")
assert result.exit_code == 0
assert f"@{other_user.username} mentioned you" in result.stdout
assert status.id in result.stdout
assert text in result.stdout
def test_notifications_follow(app, user, friend_user, run_as):
result = run_as(friend_user, cli.timelines.notifications)
assert result.exit_code == 0
assert f"@{user.username} now follows you" in result.stdout
result = run_as(friend_user, cli.timelines.notifications, "--mentions")
assert result.exit_code == 0
assert "now follows you" not in result.stdout
def _post_status(app, user, text=None) -> Status:
text = text or str(uuid4())
response = api.post_status(app, user, text)
return from_dict(Status, response.json())

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -1,60 +0,0 @@
from toot import App, User, api, config, auth
from tests.utils import retval
def test_register_app(monkeypatch):
app_data = {'id': 100, 'client_id': 'cid', 'client_secret': 'cs'}
def assert_app(app):
assert isinstance(app, App)
assert app.instance == "foo.bar"
assert app.base_url == "https://foo.bar"
assert app.client_id == "cid"
assert app.client_secret == "cs"
monkeypatch.setattr(api, 'create_app', retval(app_data))
monkeypatch.setattr(api, 'get_instance', retval({"title": "foo", "version": "1", "uri": "bezdomni.net"}))
monkeypatch.setattr(config, 'save_app', assert_app)
app = auth.register_app("foo.bar", "https://foo.bar")
assert_app(app)
def test_create_app_from_config(monkeypatch):
"""When there is saved config, it's returned"""
monkeypatch.setattr(config, 'load_app', retval("loaded app"))
monkeypatch.setattr(api, 'get_instance', retval({"title": "foo", "version": "1", "uri": "bezdomni.net"}))
app = auth.create_app_interactive("https://bezdomni.net")
assert app == 'loaded app'
def test_create_app_registered(monkeypatch):
"""When there is no saved config, a new app is registered"""
monkeypatch.setattr(config, 'load_app', retval(None))
monkeypatch.setattr(auth, 'register_app', retval("registered app"))
monkeypatch.setattr(api, 'get_instance', retval({"title": "foo", "version": "1", "uri": "bezdomni.net"}))
app = auth.create_app_interactive("bezdomni.net")
assert app == 'registered app'
def test_create_user(monkeypatch):
app = App(4, 5, 6, 7)
def assert_user(user, activate=True):
assert activate
assert isinstance(user, User)
assert user.instance == app.instance
assert user.username == "foo"
assert user.access_token == "abc"
monkeypatch.setattr(config, 'save_user', assert_user)
monkeypatch.setattr(api, 'verify_credentials', lambda x, y: {"username": "foo"})
user = auth.create_user(app, 'abc')
assert_user(user)
#
# TODO: figure out how to mock input so the rest can be tested
#

Wyświetl plik

@ -60,6 +60,7 @@ def test_extract_active_when_no_active_user(sample_config):
def test_save_app(sample_config):
pytest.skip("TODO: fix mocking")
app = App('xxx.yyy', 2, 3, 4)
app2 = App('moo.foo', 5, 6, 7)
@ -106,6 +107,7 @@ def test_save_app(sample_config):
def test_delete_app(sample_config):
pytest.skip("TODO: fix mocking")
app = App('foo.social', 2, 3, 4)
app_count = len(sample_config['apps'])

Wyświetl plik

@ -1,674 +0,0 @@
import io
import pytest
import re
from collections import namedtuple
from unittest import mock
from toot import console, User, App, http
from toot.exceptions import ConsoleError
from tests.utils import MockResponse
app = App('habunek.com', 'https://habunek.com', 'foo', 'bar')
user = User('habunek.com', 'ivan@habunek.com', 'xxx')
MockUuid = namedtuple("MockUuid", ["hex"])
def uncolorize(text):
"""Remove ANSI color sequences from a string"""
return re.sub(r'\x1b[^m]*m', '', text)
def test_print_usage(capsys):
console.print_usage()
out, err = capsys.readouterr()
assert "toot - a Mastodon CLI client" in out
@mock.patch('uuid.uuid4')
@mock.patch('toot.http.post')
def test_post_defaults(mock_post, mock_uuid, capsys):
mock_uuid.return_value = MockUuid("rock-on")
mock_post.return_value = MockResponse({
'url': 'https://habunek.com/@ihabunek/1234567890'
})
console.run_command(app, user, 'post', ['Hello world'])
mock_post.assert_called_once_with(app, user, '/api/v1/statuses', json={
'status': 'Hello world',
'visibility': 'public',
'media_ids': [],
'sensitive': False,
}, headers={"Idempotency-Key": "rock-on"})
out, err = capsys.readouterr()
assert 'Toot posted' in out
assert 'https://habunek.com/@ihabunek/1234567890' in out
assert not err
@mock.patch('uuid.uuid4')
@mock.patch('toot.http.post')
def test_post_with_options(mock_post, mock_uuid, capsys):
mock_uuid.return_value = MockUuid("up-the-irons")
args = [
'Hello world',
'--visibility', 'unlisted',
'--sensitive',
'--spoiler-text', 'Spoiler!',
'--reply-to', '123a',
'--language', 'hr',
]
mock_post.return_value = MockResponse({
'url': 'https://habunek.com/@ihabunek/1234567890'
})
console.run_command(app, user, 'post', args)
mock_post.assert_called_once_with(app, user, '/api/v1/statuses', json={
'status': 'Hello world',
'media_ids': [],
'visibility': 'unlisted',
'sensitive': True,
'spoiler_text': "Spoiler!",
'in_reply_to_id': '123a',
'language': 'hr',
}, headers={"Idempotency-Key": "up-the-irons"})
out, err = capsys.readouterr()
assert 'Toot posted' in out
assert 'https://habunek.com/@ihabunek/1234567890' in out
assert not err
def test_post_invalid_visibility(capsys):
args = ['Hello world', '--visibility', 'foo']
with pytest.raises(SystemExit):
console.run_command(app, user, 'post', args)
out, err = capsys.readouterr()
assert "invalid visibility value: 'foo'" in err
def test_post_invalid_media(capsys):
args = ['Hello world', '--media', 'does_not_exist.jpg']
with pytest.raises(SystemExit):
console.run_command(app, user, 'post', args)
out, err = capsys.readouterr()
assert "can't open 'does_not_exist.jpg'" in err
@mock.patch('toot.http.delete')
def test_delete(mock_delete, capsys):
console.run_command(app, user, 'delete', ['12321'])
mock_delete.assert_called_once_with(app, user, '/api/v1/statuses/12321')
out, err = capsys.readouterr()
assert 'Status deleted' in out
assert not err
@mock.patch('toot.http.get')
def test_timeline(mock_get, monkeypatch, capsys):
mock_get.return_value = MockResponse([{
'id': '111111111111111111',
'account': {
'display_name': 'Frank Zappa 🎸',
'last_status_at': '2017-04-12T15:53:18.174Z',
'acct': 'fz'
},
'created_at': '2017-04-12T15:53:18.174Z',
'content': "<p>The computer can&apos;t tell you the emotional story. It can give you the exact mathematical design, but what's missing is the eyebrows.</p>",
'reblog': None,
'in_reply_to_id': None,
'media_attachments': [],
}])
console.run_command(app, user, 'timeline', ['--once'])
mock_get.assert_called_once_with(app, user, '/api/v1/timelines/home', {'limit': 10})
out, err = capsys.readouterr()
lines = out.split("\n")
assert "Frank Zappa 🎸" in lines[1]
assert "@fz" in lines[1]
assert "2017-04-12 15:53 UTC" in lines[1]
assert (
"The computer can't tell you the emotional story. It can give you the "
"exact mathematical design, but\nwhat's missing is the eyebrows." in out)
assert "111111111111111111" in lines[-3]
assert err == ""
@mock.patch('toot.http.get')
def test_timeline_with_re(mock_get, monkeypatch, capsys):
mock_get.return_value = MockResponse([{
'id': '111111111111111111',
'created_at': '2017-04-12T15:53:18.174Z',
'account': {
'display_name': 'Frank Zappa',
'acct': 'fz'
},
'reblog': {
'created_at': '2017-04-12T15:53:18.174Z',
'account': {
'display_name': 'Johnny Cash',
'last_status_at': '2011-04-12',
'acct': 'jc'
},
'content': "<p>The computer can&apos;t tell you the emotional story. It can give you the exact mathematical design, but what's missing is the eyebrows.</p>",
'media_attachments': [],
},
'in_reply_to_id': '111111111111111110',
'media_attachments': [],
}])
console.run_command(app, user, 'timeline', ['--once'])
mock_get.assert_called_once_with(app, user, '/api/v1/timelines/home', {'limit': 10})
out, err = capsys.readouterr()
lines = uncolorize(out).split("\n")
assert "Johnny Cash" in lines[1]
assert "@jc" in lines[1]
assert "2017-04-12 15:53 UTC" in lines[1]
assert (
"The computer can't tell you the emotional story. It can give you the "
"exact mathematical design, but\nwhat's missing is the eyebrows." in out)
assert "111111111111111111" in lines[-3]
assert "↻ @fz boosted" in lines[-3]
assert err == ""
@mock.patch('toot.http.get')
def test_thread(mock_get, monkeypatch, capsys):
mock_get.side_effect = [
MockResponse({
'id': '111111111111111111',
'account': {
'display_name': 'Frank Zappa',
'acct': 'fz'
},
'created_at': '2017-04-12T15:53:18.174Z',
'content': "my response in the middle",
'reblog': None,
'in_reply_to_id': '111111111111111110',
'media_attachments': [],
}),
MockResponse({
'ancestors': [{
'id': '111111111111111110',
'account': {
'display_name': 'Frank Zappa',
'acct': 'fz'
},
'created_at': '2017-04-12T15:53:18.174Z',
'content': "original content",
'media_attachments': [],
'reblog': None,
'in_reply_to_id': None}],
'descendants': [{
'id': '111111111111111112',
'account': {
'display_name': 'Frank Zappa',
'acct': 'fz'
},
'created_at': '2017-04-12T15:53:18.174Z',
'content': "response message",
'media_attachments': [],
'reblog': None,
'in_reply_to_id': '111111111111111111'}],
}),
]
console.run_command(app, user, 'thread', ['111111111111111111'])
calls = [
mock.call(app, user, '/api/v1/statuses/111111111111111111'),
mock.call(app, user, '/api/v1/statuses/111111111111111111/context'),
]
mock_get.assert_has_calls(calls, any_order=False)
out, err = capsys.readouterr()
assert not err
# Display order
assert out.index('original content') < out.index('my response in the middle')
assert out.index('my response in the middle') < out.index('response message')
assert "original content" in out
assert "my response in the middle" in out
assert "response message" in out
assert "Frank Zappa" in out
assert "@fz" in out
assert "111111111111111111" in out
assert "In reply to" in out
@mock.patch('toot.http.get')
def test_reblogged_by(mock_get, monkeypatch, capsys):
mock_get.return_value = MockResponse([{
'display_name': 'Terry Bozzio',
'acct': 'bozzio@drummers.social',
}, {
'display_name': 'Dweezil',
'acct': 'dweezil@zappafamily.social',
}])
console.run_command(app, user, 'reblogged_by', ['111111111111111111'])
calls = [
mock.call(app, user, '/api/v1/statuses/111111111111111111/reblogged_by'),
]
mock_get.assert_has_calls(calls, any_order=False)
out, err = capsys.readouterr()
# Display order
expected = "\n".join([
"Terry Bozzio",
" @bozzio@drummers.social",
"Dweezil",
" @dweezil@zappafamily.social",
"",
])
assert out == expected
@mock.patch('toot.http.post')
def test_upload(mock_post, capsys):
mock_post.return_value = MockResponse({
'id': 123,
'preview_url': 'https://bigfish.software/789/012',
'url': 'https://bigfish.software/345/678',
'type': 'image',
})
console.run_command(app, user, 'upload', [__file__])
assert mock_post.call_count == 1
args, kwargs = http.post.call_args
assert args == (app, user, '/api/v2/media')
assert isinstance(kwargs['files']['file'], io.BufferedReader)
out, err = capsys.readouterr()
assert "Uploading media" in out
assert __file__ in out
@mock.patch('toot.http.get')
def test_search(mock_get, capsys):
mock_get.return_value = MockResponse({
'hashtags': [
{
'history': [],
'name': 'foo',
'url': 'https://mastodon.social/tags/foo'
},
{
'history': [],
'name': 'bar',
'url': 'https://mastodon.social/tags/bar'
},
{
'history': [],
'name': 'baz',
'url': 'https://mastodon.social/tags/baz'
},
],
'accounts': [{
'acct': 'thequeen',
'display_name': 'Freddy Mercury'
}, {
'acct': 'thequeen@other.instance',
'display_name': 'Mercury Freddy'
}],
'statuses': [],
})
console.run_command(app, user, 'search', ['freddy'])
mock_get.assert_called_once_with(app, user, '/api/v2/search', {
'q': 'freddy',
'type': None,
'resolve': False,
})
out, err = capsys.readouterr()
out = uncolorize(out)
assert "Hashtags:\n#foo, #bar, #baz" in out
assert "Accounts:" in out
assert "@thequeen Freddy Mercury" in out
assert "@thequeen@other.instance Mercury Freddy" in out
@mock.patch('toot.http.post')
@mock.patch('toot.http.get')
def test_follow(mock_get, mock_post, capsys):
mock_get.return_value = MockResponse({
"accounts": [
{"id": 123, "acct": "blixa@other.acc"},
{"id": 321, "acct": "blixa"},
]
})
mock_post.return_value = MockResponse()
console.run_command(app, user, 'follow', ['blixa'])
mock_get.assert_called_once_with(app, user, '/api/v2/search', {'q': 'blixa', 'type': 'accounts', 'resolve': True})
mock_post.assert_called_once_with(app, user, '/api/v1/accounts/321/follow')
out, err = capsys.readouterr()
assert "You are now following blixa" in out
@mock.patch('toot.http.post')
@mock.patch('toot.http.get')
def test_follow_case_insensitive(mock_get, mock_post, capsys):
mock_get.return_value = MockResponse({
"accounts": [
{"id": 123, "acct": "blixa@other.acc"},
{"id": 321, "acct": "blixa"},
]
})
mock_post.return_value = MockResponse()
console.run_command(app, user, 'follow', ['bLiXa@oThEr.aCc'])
mock_get.assert_called_once_with(app, user, '/api/v2/search', {'q': 'bLiXa@oThEr.aCc', 'type': 'accounts', 'resolve': True})
mock_post.assert_called_once_with(app, user, '/api/v1/accounts/123/follow')
out, err = capsys.readouterr()
assert "You are now following bLiXa@oThEr.aCc" in out
@mock.patch('toot.http.get')
def test_follow_not_found(mock_get, capsys):
mock_get.return_value = MockResponse({"accounts": []})
with pytest.raises(ConsoleError) as ex:
console.run_command(app, user, 'follow', ['blixa'])
mock_get.assert_called_once_with(app, user, '/api/v2/search', {'q': 'blixa', 'type': 'accounts', 'resolve': True})
assert "Account not found" == str(ex.value)
@mock.patch('toot.http.post')
@mock.patch('toot.http.get')
def test_unfollow(mock_get, mock_post, capsys):
mock_get.return_value = MockResponse({
"accounts": [
{'id': 123, 'acct': 'blixa@other.acc'},
{'id': 321, 'acct': 'blixa'},
]
})
mock_post.return_value = MockResponse()
console.run_command(app, user, 'unfollow', ['blixa'])
mock_get.assert_called_once_with(app, user, '/api/v2/search', {'q': 'blixa', 'type': 'accounts', 'resolve': True})
mock_post.assert_called_once_with(app, user, '/api/v1/accounts/321/unfollow')
out, err = capsys.readouterr()
assert "You are no longer following blixa" in out
@mock.patch('toot.http.get')
def test_unfollow_not_found(mock_get, capsys):
mock_get.return_value = MockResponse({"accounts": []})
with pytest.raises(ConsoleError) as ex:
console.run_command(app, user, 'unfollow', ['blixa'])
mock_get.assert_called_once_with(app, user, '/api/v2/search', {'q': 'blixa', 'type': 'accounts', 'resolve': True})
assert "Account not found" == str(ex.value)
@mock.patch('toot.http.get')
def test_whoami(mock_get, capsys):
mock_get.return_value = MockResponse({
'acct': 'ihabunek',
'avatar': 'https://files.mastodon.social/accounts/avatars/000/046/103/original/6a1304e135cac514.jpg?1491312434',
'avatar_static': 'https://files.mastodon.social/accounts/avatars/000/046/103/original/6a1304e135cac514.jpg?1491312434',
'created_at': '2017-04-04T13:23:09.777Z',
'display_name': 'Ivan Habunek',
'followers_count': 5,
'following_count': 9,
'header': '/headers/original/missing.png',
'header_static': '/headers/original/missing.png',
'id': 46103,
'locked': False,
'note': 'A developer.',
'statuses_count': 19,
'url': 'https://mastodon.social/@ihabunek',
'username': 'ihabunek',
'fields': []
})
console.run_command(app, user, 'whoami', [])
mock_get.assert_called_once_with(app, user, '/api/v1/accounts/verify_credentials')
out, err = capsys.readouterr()
out = uncolorize(out)
assert "@ihabunek Ivan Habunek" in out
assert "A developer." in out
assert "https://mastodon.social/@ihabunek" in out
assert "ID: 46103" in out
assert "Since: 2017-04-04" in out
assert "Followers: 5" in out
assert "Following: 9" in out
assert "Statuses: 19" in out
@mock.patch('toot.http.get')
def test_notifications(mock_get, capsys):
mock_get.return_value = MockResponse([{
'id': '1',
'type': 'follow',
'created_at': '2019-02-16T07:01:20.714Z',
'account': {
'display_name': 'Frank Zappa',
'acct': 'frank@zappa.social',
},
}, {
'id': '2',
'type': 'mention',
'created_at': '2017-01-12T12:12:12.0Z',
'account': {
'display_name': 'Dweezil Zappa',
'acct': 'dweezil@zappa.social',
},
'status': {
'id': '111111111111111111',
'account': {
'display_name': 'Dweezil Zappa',
'acct': 'dweezil@zappa.social',
},
'created_at': '2017-04-12T15:53:18.174Z',
'content': "<p>We still have fans in 2017 @fan123</p>",
'reblog': None,
'in_reply_to_id': None,
'media_attachments': [],
},
}, {
'id': '3',
'type': 'reblog',
'created_at': '1983-11-03T03:03:03.333Z',
'account': {
'display_name': 'Terry Bozzio',
'acct': 'terry@bozzio.social',
},
'status': {
'id': '1234',
'account': {
'display_name': 'Zappa Fan',
'acct': 'fan123@zappa-fans.social'
},
'created_at': '1983-11-04T15:53:18.174Z',
'content': "<p>The Black Page, a masterpiece</p>",
'reblog': None,
'in_reply_to_id': None,
'media_attachments': [],
},
}, {
'id': '4',
'type': 'favourite',
'created_at': '1983-12-13T01:02:03.444Z',
'account': {
'display_name': 'Zappa Old Fan',
'acct': 'fan9@zappa-fans.social',
},
'status': {
'id': '1234',
'account': {
'display_name': 'Zappa Fan',
'acct': 'fan123@zappa-fans.social'
},
'created_at': '1983-11-04T15:53:18.174Z',
'content': "<p>The Black Page, a masterpiece</p>",
'reblog': None,
'in_reply_to_id': None,
'media_attachments': [],
},
}])
console.run_command(app, user, 'notifications', [])
mock_get.assert_called_once_with(app, user, '/api/v1/notifications', {'exclude_types[]': [], 'limit': 20})
out, err = capsys.readouterr()
out = uncolorize(out)
assert not err
assert out == "\n".join([
"────────────────────────────────────────────────────────────────────────────────────────────────────",
"Frank Zappa @frank@zappa.social now follows you",
"────────────────────────────────────────────────────────────────────────────────────────────────────",
"Dweezil Zappa @dweezil@zappa.social mentioned you in",
"Dweezil Zappa @dweezil@zappa.social 2017-04-12 15:53 UTC",
"",
"We still have fans in 2017 @fan123",
"",
"ID 111111111111111111 ",
"────────────────────────────────────────────────────────────────────────────────────────────────────",
"Terry Bozzio @terry@bozzio.social reblogged your status",
"Zappa Fan @fan123@zappa-fans.social 1983-11-04 15:53 UTC",
"",
"The Black Page, a masterpiece",
"",
"ID 1234 ",
"────────────────────────────────────────────────────────────────────────────────────────────────────",
"Zappa Old Fan @fan9@zappa-fans.social favourited your status",
"Zappa Fan @fan123@zappa-fans.social 1983-11-04 15:53 UTC",
"",
"The Black Page, a masterpiece",
"",
"ID 1234 ",
"────────────────────────────────────────────────────────────────────────────────────────────────────",
"",
])
@mock.patch('toot.http.get')
def test_notifications_empty(mock_get, capsys):
mock_get.return_value = MockResponse([])
console.run_command(app, user, 'notifications', [])
mock_get.assert_called_once_with(app, user, '/api/v1/notifications', {'exclude_types[]': [], 'limit': 20})
out, err = capsys.readouterr()
out = uncolorize(out)
assert not err
assert out == "No notification\n"
@mock.patch('toot.http.post')
def test_notifications_clear(mock_post, capsys):
console.run_command(app, user, 'notifications', ['--clear'])
out, err = capsys.readouterr()
out = uncolorize(out)
mock_post.assert_called_once_with(app, user, '/api/v1/notifications/clear')
assert not err
assert out == 'Cleared notifications\n'
def u(user_id, access_token="abc"):
username, instance = user_id.split("@")
return {
"instance": instance,
"username": username,
"access_token": access_token,
}
@mock.patch('toot.config.save_config')
@mock.patch('toot.config.load_config')
def test_logout(mock_load, mock_save, capsys):
mock_load.return_value = {
"users": {
"king@gizzard.social": u("king@gizzard.social"),
"lizard@wizard.social": u("lizard@wizard.social"),
},
"active_user": "king@gizzard.social",
}
console.run_command(app, user, "logout", ["king@gizzard.social"])
mock_save.assert_called_once_with({
'users': {
'lizard@wizard.social': u("lizard@wizard.social")
},
'active_user': None
})
out, err = capsys.readouterr()
assert "✓ User king@gizzard.social logged out" in out
@mock.patch('toot.config.save_config')
@mock.patch('toot.config.load_config')
def test_activate(mock_load, mock_save, capsys):
mock_load.return_value = {
"users": {
"king@gizzard.social": u("king@gizzard.social"),
"lizard@wizard.social": u("lizard@wizard.social"),
},
"active_user": "king@gizzard.social",
}
console.run_command(app, user, "activate", ["lizard@wizard.social"])
mock_save.assert_called_once_with({
'users': {
"king@gizzard.social": u("king@gizzard.social"),
'lizard@wizard.social': u("lizard@wizard.social")
},
'active_user': "lizard@wizard.social"
})
out, err = capsys.readouterr()
assert "✓ User lizard@wizard.social active" in out

Wyświetl plik

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

Wyświetl plik

@ -1,8 +1,13 @@
from argparse import ArgumentTypeError
import click
import pytest
import sys
from toot.console import duration
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
def test_pad():
@ -162,6 +167,9 @@ def test_wc_wrap_indented():
def test_duration():
def duration(value):
return validate_duration(None, None, value)
# Long hand
assert duration("1 second") == 1
assert duration("1 seconds") == 1
@ -189,15 +197,125 @@ def test_duration():
assert duration("5d 10h 3m 1s") == 5 * 86400 + 10 * 3600 + 3 * 60 + 1
assert duration("5d10h3m1s") == 5 * 86400 + 10 * 3600 + 3 * 60 + 1
with pytest.raises(ArgumentTypeError):
with pytest.raises(click.BadParameter):
duration("")
with pytest.raises(ArgumentTypeError):
with pytest.raises(click.BadParameter):
duration("100")
# Wrong order
with pytest.raises(ArgumentTypeError):
with pytest.raises(click.BadParameter):
duration("1m1d")
with pytest.raises(ArgumentTypeError):
with pytest.raises(click.BadParameter):
duration("banana")
def test_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

@ -0,0 +1,45 @@
from urwid import Divider, Filler, Pile
from toot.tui.richtext import url_to_widget
from urwidgets import Hyperlink, TextEmbed
from toot.tui.richtext.richtext import html_to_widgets
def test_url_to_widget():
url = "http://foo.bar"
embed_widget = url_to_widget(url)
assert isinstance(embed_widget, TextEmbed)
[(filler, length)] = embed_widget.embedded
assert length == len(url)
assert isinstance(filler, Filler)
link_widget = filler.base_widget
assert isinstance(link_widget, Hyperlink)
assert link_widget.attrib == "link"
assert link_widget.text == url
assert link_widget.uri == url
def test_html_to_widgets():
html = """
<p>foo</p>
<p>foo <b>bar</b> <i>baz</i></p>
""".strip()
[foo, divider, bar] = html_to_widgets(html)
assert isinstance(foo, Pile)
assert isinstance(divider, Divider)
assert isinstance(bar, Pile)
[(foo_embed, _)] = foo.contents
assert foo_embed.embedded == []
assert foo_embed.attrib == []
assert foo_embed.text == "foo"
[(bar_embed, _)] = bar.contents
assert bar_embed.embedded == []
assert bar_embed.attrib == [(None, 4), ("b", 3), (None, 1), ("i", 3)]
assert bar_embed.text == "foo bar baz"

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

@ -2,12 +2,28 @@ import os
import sys
from os.path import join, expanduser
from collections import namedtuple
from typing import NamedTuple
from importlib import metadata
__version__ = '0.38.2'
App = namedtuple('App', ['instance', 'base_url', 'client_id', 'client_secret'])
User = namedtuple('User', ['instance', 'username', 'access_token'])
try:
__version__ = metadata.version("toot")
except metadata.PackageNotFoundError:
__version__ = "0.0.0"
class App(NamedTuple):
instance: str
base_url: str
client_id: str
client_secret: str
class User(NamedTuple):
instance: str
username: str
access_token: str
DEFAULT_INSTANCE = 'https://mastodon.social'

Wyświetl plik

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

Wyświetl plik

@ -1,13 +1,14 @@
import mimetypes
from os import path
import re
import uuid
from os import path
from requests import Response
from typing import BinaryIO, List, Optional
from urllib.parse import urlparse, urlencode, quote
from toot import App, User, http, CLIENT_NAME, CLIENT_WEBSITE
from toot.exceptions import AuthenticationError, ConsoleError
from toot.exceptions import ConsoleError
from toot.utils import drop_empty_values, str_bool, str_bool_nullable
@ -30,26 +31,26 @@ def find_account(app, user, account_name):
normalized_name = username
response = search(app, user, account_name, type="accounts", resolve=True)
for account in response["accounts"]:
for account in response.json()["accounts"]:
if account["acct"].lower() == normalized_name:
return account
raise ConsoleError("Account not found")
def _account_action(app, user, account, action):
def _account_action(app, user, account, action) -> Response:
url = f"/api/v1/accounts/{account}/{action}"
return http.post(app, user, url).json()
return http.post(app, user, url)
def _status_action(app, user, status_id, action, data=None):
def _status_action(app, user, status_id, action, data=None) -> Response:
url = f"/api/v1/statuses/{status_id}/{action}"
return http.post(app, user, url, data=data).json()
return http.post(app, user, url, data=data)
def _tag_action(app, user, tag_name, action):
def _tag_action(app, user, tag_name, action) -> Response:
url = f"/api/v1/tags/{tag_name}/{action}"
return http.post(app, user, url).json()
return http.post(app, user, url)
def create_app(base_url):
@ -139,7 +140,7 @@ def fetch_app_token(app):
return http.anon_post(f"{app.base_url}/oauth/token", json=json).json()
def login(app, username, password):
def login(app: App, username: str, password: str):
url = app.base_url + '/oauth/token'
data = {
@ -151,16 +152,10 @@ def login(app, username, password):
'scope': SCOPES,
}
response = http.anon_post(url, data=data, allow_redirects=False)
# If auth fails, it redirects to the login page
if response.is_redirect:
raise AuthenticationError()
return response.json()
return http.anon_post(url, data=data).json()
def get_browser_login_url(app):
def get_browser_login_url(app: App) -> str:
"""Returns the URL for manual log in via browser"""
return "{}/oauth/authorize/?{}".format(app.base_url, urlencode({
"response_type": "code",
@ -170,7 +165,7 @@ def get_browser_login_url(app):
}))
def request_access_token(app, authorization_code):
def request_access_token(app: App, authorization_code: str):
url = app.base_url + '/oauth/token'
data = {
@ -188,7 +183,7 @@ def post_status(
app,
user,
status,
visibility='public',
visibility=None,
media_ids=None,
sensitive=False,
spoiler_text=None,
@ -200,7 +195,7 @@ def post_status(
poll_expires_in=None,
poll_multiple=None,
poll_hide_totals=None,
):
) -> Response:
"""
Publish a new status.
https://docs.joinmastodon.org/methods/statuses/#create
@ -232,7 +227,53 @@ def post_status(
"hide_totals": poll_hide_totals,
}
return http.post(app, user, '/api/v1/statuses', json=data, headers=headers).json()
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):
@ -240,7 +281,16 @@ def fetch_status(app, user, id):
Fetch a single status
https://docs.joinmastodon.org/methods/statuses/#get
"""
return http.get(app, user, f"/api/v1/statuses/{id}").json()
return http.get(app, user, f"/api/v1/statuses/{id}")
def fetch_status_source(app, user, id):
"""
Fetch the source (original text) for a single status.
This only works on local toots.
https://docs.joinmastodon.org/methods/statuses/#source
"""
return http.get(app, user, f"/api/v1/statuses/{id}/source")
def scheduled_statuses(app, user):
@ -295,14 +345,36 @@ def translate(app, user, status_id):
return _status_action(app, user, status_id, 'translate')
def context(app, user, status_id):
def context(app, user, status_id) -> Response:
url = f"/api/v1/statuses/{status_id}/context"
return http.get(app, user, url).json()
return http.get(app, user, url)
def reblogged_by(app, user, status_id):
def reblogged_by(app, user, status_id) -> Response:
url = f"/api/v1/statuses/{status_id}/reblogged_by"
return http.get(app, user, url).json()
return http.get(app, user, url)
def get_timeline_generator(
app: Optional[App],
user: Optional[User],
account: Optional[str] = None,
list_id: Optional[str] = None,
tag: Optional[str] = None,
local: bool = False,
public: bool = False,
limit: int = 20, # TODO
):
if public:
return public_timeline_generator(app, user, local=local, limit=limit)
elif tag:
return tag_timeline_generator(app, user, tag, local=local, limit=limit)
elif account:
return account_timeline_generator(app, user, account, limit=limit)
elif list_id:
return timeline_list_generator(app, user, list_id, limit=limit)
else:
return home_timeline_generator(app, user, limit=limit)
def _get_next_path(headers):
@ -314,6 +386,14 @@ def _get_next_path(headers):
return "?".join([parsed.path, parsed.query])
def _get_next_url(headers) -> Optional[str]:
"""Given timeline response headers, returns the url to the next batch"""
links = headers.get('Link', '')
match = re.match('<([^>]+)>; rel="next"', links)
if match:
return match.group(1)
def _timeline_generator(app, user, path, params=None):
while path:
response = http.get(app, user, path, params)
@ -374,7 +454,7 @@ def conversation_timeline_generator(app, user, limit=20):
return _conversation_timeline_generator(app, user, path, params)
def account_timeline_generator(app: App, user: User, account_name: str, replies=False, reblogs=False, limit=20):
def account_timeline_generator(app, user, account_name: str, replies=False, reblogs=False, limit=20):
account = find_account(app, user, account_name)
path = f"/api/v1/accounts/{account['id']}/statuses"
params = {"limit": limit, "exclude_replies": not replies, "exclude_reblogs": not reblogs}
@ -386,24 +466,23 @@ def timeline_list_generator(app, user, list_id, limit=20):
return _timeline_generator(app, user, path, {'limit': limit})
def _anon_timeline_generator(instance, path, params=None):
while path:
url = f"https://{instance}{path}"
def _anon_timeline_generator(url, params=None):
while url:
response = http.anon_get(url, params)
yield response.json()
path = _get_next_path(response.headers)
url = _get_next_url(response.headers)
def anon_public_timeline_generator(instance, local=False, limit=20):
path = '/api/v1/timelines/public'
params = {'local': str_bool(local), 'limit': limit}
return _anon_timeline_generator(instance, path, params)
def anon_public_timeline_generator(base_url, local=False, limit=20):
query = urlencode({"local": str_bool(local), "limit": limit})
url = f"{base_url}/api/v1/timelines/public?{query}"
return _anon_timeline_generator(url)
def anon_tag_timeline_generator(instance, hashtag, local=False, limit=20):
path = f"/api/v1/timelines/tag/{quote(hashtag)}"
params = {'local': str_bool(local), 'limit': limit}
return _anon_timeline_generator(instance, path, params)
def anon_tag_timeline_generator(base_url, hashtag, local=False, limit=20):
query = urlencode({"local": str_bool(local), "limit": limit})
url = f"{base_url}/api/v1/timelines/tag/{quote(hashtag)}?{query}"
return _anon_timeline_generator(url)
def get_media(app: App, user: User, id: str):
@ -426,7 +505,7 @@ def upload_media(
"thumbnail": _add_mime_type(thumbnail)
})
return http.post(app, user, "/api/v2/media", data=data, files=files).json()
return http.post(app, user, "/api/v2/media", data=data, files=files)
def _add_mime_type(file):
@ -451,11 +530,13 @@ def search(app, user, query, resolve=False, type=None):
Perform a search.
https://docs.joinmastodon.org/methods/search/#v2
"""
return http.get(app, user, "/api/v2/search", {
params = drop_empty_values({
"q": query,
"resolve": resolve,
"resolve": str_bool(resolve),
"type": type
}).json()
})
return http.get(app, user, "/api/v2/search", params)
def follow(app, user, account):
@ -466,11 +547,11 @@ def unfollow(app, user, account):
return _account_action(app, user, account, 'unfollow')
def follow_tag(app, user, tag_name):
def follow_tag(app, user, tag_name) -> Response:
return _tag_action(app, user, tag_name, 'follow')
def unfollow_tag(app, user, tag_name):
def unfollow_tag(app, user, tag_name) -> Response:
return _tag_action(app, user, tag_name, 'unfollow')
@ -498,6 +579,43 @@ def followed_tags(app, user):
return _get_response_list(app, user, path)
def featured_tags(app, user):
return http.get(app, user, "/api/v1/featured_tags")
def feature_tag(app, user, tag: str) -> Response:
return http.post(app, user, "/api/v1/featured_tags", data={"name": tag})
def unfeature_tag(app, user, tag_id: str) -> Response:
return http.delete(app, user, f"/api/v1/featured_tags/{tag_id}")
def find_tag(app, user, tag) -> Optional[dict]:
"""Find a hashtag by tag name or ID"""
tag = tag.lstrip("#")
results = search(app, user, tag, type="hashtags").json()
return next(
(
t for t in results["hashtags"]
if t["name"].lower() == tag.lstrip("#").lower() or t["id"] == tag
),
None
)
def find_featured_tag(app, user, tag) -> Optional[dict]:
"""Find a featured tag by tag name or ID"""
return next(
(
t for t in featured_tags(app, user).json()
if t["name"].lower() == tag.lstrip("#").lower() or t["id"] == tag
),
None
)
def whois(app, user, account):
return http.get(app, user, f'/api/v1/accounts/{account}').json()
@ -537,17 +655,12 @@ def blocked(app, user):
return _get_response_list(app, user, "/api/v1/blocks")
def verify_credentials(app, user):
return http.get(app, user, '/api/v1/accounts/verify_credentials').json()
def verify_credentials(app, user) -> Response:
return http.get(app, user, '/api/v1/accounts/verify_credentials')
def single_status(app, user, status_id):
url = f"/api/v1/statuses/{status_id}"
return http.get(app, user, url).json()
def get_notifications(app, user, exclude_types=[], limit=20):
params = {"exclude_types[]": exclude_types, "limit": limit}
def get_notifications(app, user, types=[], exclude_types=[], limit=20):
params = {"types[]": types, "exclude_types[]": exclude_types, "limit": limit}
return http.get(app, user, '/api/v1/notifications', params).json()
@ -555,22 +668,17 @@ def clear_notifications(app, user):
http.post(app, user, '/api/v1/notifications/clear')
def get_instance(base_url):
def get_instance(base_url: str) -> Response:
url = f"{base_url}/api/v1/instance"
return http.anon_get(url).json()
return http.anon_get(url)
def get_preferences(app, user) -> Response:
return http.get(app, user, '/api/v1/preferences')
def get_lists(app, user):
path = "/api/v1/lists"
return _get_response_list(app, user, path)
def find_list_id(app, user, title):
lists = get_lists(app, user)
for list_item in lists:
if list_item["title"] == title:
return list_item["id"]
return None
return http.get(app, user, "/api/v1/lists").json()
def get_list_accounts(app, user, list_id):
@ -578,12 +686,12 @@ def get_list_accounts(app, user, list_id):
return _get_response_list(app, user, path)
def create_list(app, user, title, replies_policy):
def create_list(app, user, title, replies_policy="none"):
url = "/api/v1/lists"
json = {'title': title}
if replies_policy:
json['replies_policy'] = replies_policy
return http.post(app, user, url, json=json).json()
return http.post(app, user, url, json=json)
def delete_list(app, user, id):
@ -593,7 +701,7 @@ def delete_list(app, user, id):
def add_accounts_to_list(app, user, list_id, account_ids):
url = f"/api/v1/lists/{list_id}/accounts"
json = {'account_ids': account_ids}
return http.post(app, user, url, json=json).json()
return http.post(app, user, url, json=json)
def remove_accounts_from_list(app, user, list_id, account_ids):

Wyświetl plik

@ -1,18 +1,19 @@
import sys
import webbrowser
from builtins import input
from getpass import getpass
from toot import api, config, DEFAULT_INSTANCE, User, App
from toot import api, config, User, App
from toot.entities import from_dict, Instance
from toot.exceptions import ApiError, ConsoleError
from toot.output import print_out
from urllib.parse import urlparse
def register_app(domain, base_url):
def find_instance(base_url: str) -> Instance:
try:
instance = api.get_instance(base_url).json()
return from_dict(Instance, instance)
except Exception:
raise ConsoleError(f"Instance not found at {base_url}")
def register_app(domain: str, base_url: str) -> App:
try:
print_out("Registering application...")
response = api.create_app(base_url)
except ApiError:
raise ConsoleError("Registration failed.")
@ -20,114 +21,54 @@ def register_app(domain, base_url):
app = App(domain, base_url, response['client_id'], response['client_secret'])
config.save_app(app)
print_out("Application tokens saved.")
return app
def create_app_interactive(base_url):
if not base_url:
print_out(f"Enter instance URL [<green>{DEFAULT_INSTANCE}</green>]: ", end="")
base_url = input()
if not base_url:
base_url = DEFAULT_INSTANCE
domain = get_instance_domain(base_url)
def get_or_create_app(base_url: str) -> App:
instance = find_instance(base_url)
domain = _get_instance_domain(instance)
return config.load_app(domain) or register_app(domain, base_url)
def get_instance_domain(base_url):
print_out("Looking up instance info...")
instance = api.get_instance(base_url)
print_out(
f"Found instance <blue>{instance['title']}</blue> "
f"running Mastodon version <yellow>{instance['version']}</yellow>"
)
# Pleroma and its forks return an actual URI here, rather than a
# domain name like Mastodon. This is contrary to the spec.¯
# in that case, parse out the domain and return it.
parsed_uri = urlparse(instance["uri"])
if parsed_uri.netloc:
# Pleroma, Akkoma, GotoSocial, etc.
return parsed_uri.netloc
else:
# Others including Mastodon servers
return parsed_uri.path
# NB: when updating to v2 instance endpoint, this field has been renamed to `domain`
def create_user(app, access_token):
def create_user(app: App, access_token: str) -> User:
# Username is not yet known at this point, so fetch it from Mastodon
user = User(app.instance, None, access_token)
creds = api.verify_credentials(app, user)
creds = api.verify_credentials(app, user).json()
user = User(app.instance, creds['username'], access_token)
user = User(app.instance, creds["username"], access_token)
config.save_user(user, activate=True)
print_out("Access token saved to config at: <green>{}</green>".format(
config.get_config_file_path()))
return user
def login_interactive(app, email=None):
print_out("Log in to <green>{}</green>".format(app.instance))
if email:
print_out("Email: <green>{}</green>".format(email))
while not email:
email = input('Email: ')
# Accept password piped from stdin, useful for testing purposes but not
# documented so people won't get ideas. Otherwise prompt for password.
if sys.stdin.isatty():
password = getpass('Password: ')
else:
password = sys.stdin.read().strip()
print_out("Password: <green>read from stdin</green>")
def login_username_password(app: App, email: str, password: str) -> User:
try:
print_out("Authenticating...")
response = api.login(app, email, password)
except ApiError:
except Exception:
raise ConsoleError("Login failed")
return create_user(app, response['access_token'])
return create_user(app, response["access_token"])
BROWSER_LOGIN_EXPLANATION = """
This authentication method requires you to log into your Mastodon instance
in your browser, where you will be asked to authorize <yellow>toot</yellow> to access
your account. When you do, you will be given an <yellow>authorization code</yellow>
which you need to paste here.
"""
def login_auth_code(app: App, authorization_code: str) -> User:
try:
response = api.request_access_token(app, authorization_code)
except Exception:
raise ConsoleError("Login failed")
return create_user(app, response["access_token"])
def login_browser_interactive(app):
url = api.get_browser_login_url(app)
print_out(BROWSER_LOGIN_EXPLANATION)
def _get_instance_domain(instance: Instance) -> str:
"""Extracts the instance domain name.
print_out("This is the login URL:")
print_out(url)
print_out("")
Pleroma and its forks return an actual URI here, rather than a domain name
like Mastodon. This is contrary to the spec.¯ in that case, parse out the
domain and return it.
yesno = input("Open link in default browser? [Y/n]")
if not yesno or yesno.lower() == 'y':
webbrowser.open(url)
authorization_code = ""
while not authorization_code:
authorization_code = input("Authorization code: ")
print_out("\nRequesting access token...")
response = api.request_access_token(app, authorization_code)
return create_user(app, response['access_token'])
TODO: when updating to v2 instance endpoint, this field has been renamed to
`domain`
"""
if instance.uri.startswith("http"):
return urlparse(instance.uri).netloc
return instance.uri

Wyświetl plik

@ -0,0 +1,182 @@
import click
import logging
import os
import sys
import typing as t
from click.shell_completion import CompletionItem
from click.types import StringParamType
from functools import wraps
from toot import App, User, config, __version__
from toot.output import print_warning
from toot.settings import get_settings
if t.TYPE_CHECKING:
import typing_extensions as te
P = te.ParamSpec("P")
R = t.TypeVar("R")
T = t.TypeVar("T")
PRIVACY_CHOICES = ["public", "unlisted", "private"]
VISIBILITY_CHOICES = ["public", "unlisted", "private", "direct"]
IMAGE_FORMAT_CHOICES = ["block", "iterm", "kitty"]
TUI_COLORS = {
"1": 1,
"16": 16,
"88": 88,
"256": 256,
"16777216": 16777216,
"24bit": 16777216,
}
TUI_COLORS_CHOICES = list(TUI_COLORS.keys())
TUI_COLORS_VALUES = list(TUI_COLORS.values())
DURATION_EXAMPLES = """e.g. "1 day", "2 hours 30 minutes", "5 minutes 30
seconds" or any combination of above. Shorthand: "1d", "2h30m", "5m30s\""""
def get_default_visibility() -> str:
return os.getenv("TOOT_POST_VISIBILITY", "public")
def get_default_map():
settings = get_settings()
common = settings.get("common", {})
commands = settings.get("commands", {})
# TODO: remove in version 1.0
tui_old = settings.get("tui", {}).copy()
if "palette" in tui_old:
del tui_old["palette"]
if tui_old:
# TODO: don't show the warning for [toot.palette]
print_warning("Settings section [tui] has been deprecated in favour of [commands.tui].")
tui_new = commands.get("tui", {})
commands["tui"] = {**tui_old, **tui_new}
return {**common, **commands}
# Tweak the Click context
# https://click.palletsprojects.com/en/8.1.x/api/#context
CONTEXT = dict(
# Enable using environment variables to set options
auto_envvar_prefix="TOOT",
# Add shorthand -h for invoking help
help_option_names=["-h", "--help"],
# Always show default values for options
show_default=True,
# Load command defaults from settings
default_map=get_default_map(),
)
class Context(t.NamedTuple):
app: t.Optional[App]
user: t.Optional[User] = None
color: bool = False
debug: bool = False
class TootObj(t.NamedTuple):
"""Data to add to Click context"""
color: bool = True
debug: bool = False
as_user: t.Optional[str] = None
# Pass a context for testing purposes
test_ctx: t.Optional[Context] = None
class AccountParamType(StringParamType):
"""Custom type to add shell completion for account names"""
name = "account"
def shell_complete(self, ctx, param, incomplete: str):
users = config.load_config()["users"].keys()
return [
CompletionItem(u)
for u in users
if u.lower().startswith(incomplete.lower())
]
class InstanceParamType(StringParamType):
"""Custom type to add shell completion for instance domains"""
name = "instance"
def shell_complete(self, ctx, param, incomplete: str):
apps = config.load_config()["apps"]
return [
CompletionItem(i)
for i in apps.keys()
if i.lower().startswith(incomplete.lower())
]
def pass_context(f: "t.Callable[te.Concatenate[Context, P], R]") -> "t.Callable[P, R]":
"""Pass the toot Context as first argument."""
@wraps(f)
def wrapped(*args: "P.args", **kwargs: "P.kwargs") -> R:
return f(get_context(), *args, **kwargs)
return wrapped
def get_context() -> Context:
click_context = click.get_current_context()
obj: TootObj = click_context.obj
# This is used to pass a context for testing, not used in normal usage
if obj.test_ctx:
return obj.test_ctx
if obj.as_user:
user, app = config.get_user_app(obj.as_user)
if not user or not app:
raise click.ClickException(f"Account '{obj.as_user}' not found. Run `toot auth` to see available accounts.")
else:
user, app = config.get_active_user_app()
if not user or not app:
raise click.ClickException("This command requires you to be logged in.")
return Context(app, user, obj.color, obj.debug)
json_option = click.option(
"--json",
is_flag=True,
default=False,
help="Print data as JSON rather than human readable text"
)
@click.group(context_settings=CONTEXT)
@click.option("-w", "--max-width", type=int, default=80, help="Maximum width for content rendered by toot")
@click.option("--debug/--no-debug", default=False, help="Log debug info to stderr")
@click.option("--color/--no-color", default=sys.stdout.isatty(), help="Use ANSI color in output")
@click.option("--as", "as_user", type=AccountParamType(), help="The account to use, overrides the active account.")
@click.version_option(__version__, message="%(prog)s v%(version)s")
@click.pass_context
def cli(ctx: click.Context, max_width: int, color: bool, debug: bool, as_user: str):
"""Toot is a Mastodon CLI"""
ctx.obj = TootObj(color, debug, as_user)
ctx.color = color
ctx.max_content_width = max_width
if debug:
logging.basicConfig(level=logging.DEBUG)
from toot.cli import accounts # noqa
from toot.cli import auth # noqa
from toot.cli import lists # noqa
from toot.cli import post # noqa
from toot.cli import read # noqa
from toot.cli import statuses # noqa
from toot.cli import tags # noqa
from toot.cli import timelines # noqa
from toot.cli import tui # noqa

Wyświetl plik

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

143
toot/cli/auth.py 100644
Wyświetl plik

@ -0,0 +1,143 @@
import click
import platform
import sys
import webbrowser
from toot import api, config, __version__
from toot.auth import get_or_create_app, login_auth_code, login_username_password
from toot.cli import AccountParamType, cli
from toot.cli.validators import validate_instance
instance_option = click.option(
"--instance", "-i", "base_url",
prompt="Enter instance URL",
default="https://mastodon.social",
callback=validate_instance,
help="""Domain or base URL of the instance to log into,
e.g. 'mastodon.social' or 'https://mastodon.social'""",
)
@cli.command()
def auth():
"""Show logged in accounts and instances"""
config_data = config.load_config()
if not config_data["users"]:
click.echo("You are not logged in to any accounts")
return
active_user = config_data["active_user"]
click.echo("Authenticated accounts:")
for uid, u in config_data["users"].items():
active_label = "ACTIVE" if active_user == uid else ""
uid = click.style(uid, fg="green")
active_label = click.style(active_label, fg="yellow")
click.echo(f"* {uid} {active_label}")
path = config.get_config_file_path()
path = click.style(path, "blue")
click.echo(f"\nAuth tokens are stored in: {path}")
@cli.command()
def env():
"""Print environment information for inclusion in bug reports."""
click.echo(f"toot {__version__}")
click.echo(f"Python {sys.version}")
click.echo(platform.platform())
@cli.command(name="login_cli")
@instance_option
@click.option("--email", "-e", help="Email address to log in with", prompt=True)
@click.option("--password", "-p", hidden=True, prompt=True, hide_input=True)
def login_cli(base_url: str, email: str, password: str):
"""
Log into an instance from the console (not recommended)
Does NOT support two factor authentication, may not work on instances
other than Mastodon, mostly useful for scripting.
"""
app = get_or_create_app(base_url)
login_username_password(app, email, password)
click.secho("✓ Successfully logged in.", fg="green")
click.echo("Access token saved to config at: ", nl=False)
click.secho(config.get_config_file_path(), fg="green")
LOGIN_EXPLANATION = """This authentication method requires you to log into your
Mastodon instance in your browser, where you will be asked to authorize toot to
access your account. When you do, you will be given an authorization code which
you need to paste here.""".replace("\n", " ")
@cli.command()
@instance_option
def login(base_url: str):
"""Log into an instance using your browser (recommended)"""
app = get_or_create_app(base_url)
url = api.get_browser_login_url(app)
click.echo(click.wrap_text(LOGIN_EXPLANATION))
click.echo("\nLogin URL:")
click.echo(url)
yesno = click.prompt("Open link in default browser? [Y/n]", default="Y", show_default=False)
if not yesno or yesno.lower() == 'y':
webbrowser.open(url)
authorization_code = ""
while not authorization_code:
authorization_code = click.prompt("Authorization code")
login_auth_code(app, authorization_code)
click.echo()
click.secho("✓ Successfully logged in.", fg="green")
@cli.command()
@click.argument("account", type=AccountParamType(), required=False)
def logout(account: str):
"""Log out of ACCOUNT, delete stored access keys"""
accounts = _get_accounts_list()
if not account:
raise click.ClickException(f"Specify account to log out:\n{accounts}")
user = config.load_user(account)
if not user:
raise click.ClickException(f"Account not found. Logged in accounts:\n{accounts}")
config.delete_user(user)
click.secho(f"✓ Account {account} logged out", fg="green")
@cli.command()
@click.argument("account", type=AccountParamType(), required=False)
def activate(account: str):
"""Switch to logged in ACCOUNT."""
accounts = _get_accounts_list()
if not account:
raise click.ClickException(f"Specify account to activate:\n{accounts}")
user = config.load_user(account)
if not user:
raise click.ClickException(f"Account not found. Logged in accounts:\n{accounts}")
config.activate_user(user)
click.secho(f"✓ Account {account} activated", fg="green")
def _get_accounts_list() -> str:
accounts = config.load_config()["users"].keys()
if not accounts:
raise click.ClickException("You're not logged into any accounts")
return "\n".join([f"* {acct}" for acct in accounts])

250
toot/cli/lists.py 100644
Wyświetl plik

@ -0,0 +1,250 @@
import click
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
@cli.group(invoke_without_command=True)
@click.pass_context
def lists(ctx: click.Context):
"""Display and manage lists"""
if ctx.invoked_subcommand is None:
print_warning("`toot lists` is deprecated in favour of `toot lists list`.\n" +
"Run `toot lists -h` to see other list-related commands.")
user, app = config.get_active_user_app()
if not user or not app:
raise click.ClickException("This command requires you to be logged in.")
data = api.get_lists(app, user)
lists = from_dict_list(List, data)
if lists:
print_lists(lists)
else:
click.echo("You have no lists defined.")
@lists.command()
@json_option
@pass_context
def list(ctx: Context, json: bool):
"""List all your lists"""
data = api.get_lists(ctx.app, ctx.user)
if json:
click.echo(pyjson.dumps(data))
else:
if data:
lists = from_dict_list(List, data)
print_lists(lists)
else:
click.echo("You have no lists defined.")
@lists.command()
@click.argument("title", required=False)
@click.option("--id", help="List ID if not title is given")
@json_option
@pass_context
def accounts(ctx: Context, title: str, id: str, json: bool):
"""List the accounts in a list"""
list_id = _get_list_id(ctx, title, id)
response = api.get_list_accounts(ctx.app, ctx.user, list_id)
if json:
click.echo(pyjson.dumps(response))
else:
print_list_accounts(response)
@lists.command()
@click.argument("title")
@click.option(
"--replies-policy",
type=click.Choice(["followed", "list", "none"]),
default="none",
help="Replies policy"
)
@json_option
@pass_context
def create(ctx: Context, title: str, replies_policy: str, json: bool):
"""Create a list"""
response = api.create_list(ctx.app, ctx.user, title=title, replies_policy=replies_policy)
if json:
print(response.text)
else:
click.secho(f"✓ List \"{title}\" created.", fg="green")
@lists.command()
@click.argument("title", required=False)
@click.option("--id", help="List ID if not title is given")
@json_option
@pass_context
def delete(ctx: Context, title: str, id: str, json: bool):
"""Delete a list"""
list_id = _get_list_id(ctx, title, id)
response = api.delete_list(ctx.app, ctx.user, list_id)
if json:
click.echo(response.text)
else:
click.secho(f"✓ List \"{title if title else id}\" deleted.", fg="green")
@lists.command()
@click.argument("title", required=False)
@click.argument("account")
@click.option("--id", help="List ID if not title is given")
@json_option
@pass_context
def add(ctx: Context, title: str, account: str, id: str, json: bool):
"""Add an account to a list"""
list_id = _get_list_id(ctx, title, id)
found_account = api.find_account(ctx.app, ctx.user, account)
try:
response = api.add_accounts_to_list(ctx.app, ctx.user, list_id, [found_account["id"]])
if json:
click.echo(response.text)
else:
click.secho(f"✓ Added account \"{account}\"", fg="green")
except Exception:
# TODO: this is slow, improve
# if we failed to add the account, try to give a
# more specific error message than "record not found"
my_accounts = api.followers(ctx.app, ctx.user, found_account["id"])
found = False
if my_accounts:
for my_account in my_accounts:
if my_account["id"] == found_account["id"]:
found = True
break
if found is False:
raise click.ClickException(f"You must follow @{account} before adding this account to a list.")
raise
@lists.command()
@click.argument("title", required=False)
@click.argument("account")
@click.option("--id", help="List ID if not title is given")
@json_option
@pass_context
def remove(ctx: Context, title: str, account: str, id: str, json: bool):
"""Remove an account from a list"""
list_id = _get_list_id(ctx, title, id)
found_account = api.find_account(ctx.app, ctx.user, account)
response = api.remove_accounts_from_list(ctx.app, ctx.user, list_id, [found_account["id"]])
if json:
click.echo(response.text)
else:
click.secho(f"✓ Removed account \"{account}\"", fg="green")
# -- Deprecated commands -------------------------------------------------------
@cli.command(name="list_accounts", hidden=True)
@click.argument("title", required=False)
@click.option("--id", help="List ID if not title is given")
@pass_context
def list_accounts(ctx: Context, title: str, id: str):
"""List the accounts in a list"""
print_warning("`toot list_accounts` is deprecated in favour of `toot lists accounts`")
list_id = _get_list_id(ctx, title, id)
response = api.get_list_accounts(ctx.app, ctx.user, list_id)
print_list_accounts(response)
@cli.command(name="list_create", hidden=True)
@click.argument("title")
@click.option(
"--replies-policy",
type=click.Choice(["followed", "list", "none"]),
default="none",
help="Replies policy"
)
@pass_context
def list_create(ctx: Context, title: str, replies_policy: str):
"""Create a list"""
print_warning("`toot list_create` is deprecated in favour of `toot lists create`")
api.create_list(ctx.app, ctx.user, title=title, replies_policy=replies_policy)
click.secho(f"✓ List \"{title}\" created.", fg="green")
@cli.command(name="list_delete", hidden=True)
@click.argument("title", required=False)
@click.option("--id", help="List ID if not title is given")
@pass_context
def list_delete(ctx: Context, title: str, id: str):
"""Delete a list"""
print_warning("`toot list_delete` is deprecated in favour of `toot lists delete`")
list_id = _get_list_id(ctx, title, id)
api.delete_list(ctx.app, ctx.user, list_id)
click.secho(f"✓ List \"{title if title else id}\" deleted.", fg="green")
@cli.command(name="list_add", hidden=True)
@click.argument("title", required=False)
@click.argument("account")
@click.option("--id", help="List ID if not title is given")
@pass_context
def list_add(ctx: Context, title: str, account: str, id: str):
"""Add an account to a list"""
print_warning("`toot list_add` is deprecated in favour of `toot lists add`")
list_id = _get_list_id(ctx, title, id)
found_account = api.find_account(ctx.app, ctx.user, account)
try:
api.add_accounts_to_list(ctx.app, ctx.user, list_id, [found_account["id"]])
except Exception:
# if we failed to add the account, try to give a
# more specific error message than "record not found"
my_accounts = api.followers(ctx.app, ctx.user, found_account["id"])
found = False
if my_accounts:
for my_account in my_accounts:
if my_account["id"] == found_account["id"]:
found = True
break
if found is False:
raise click.ClickException(f"You must follow @{account} before adding this account to a list.")
raise
click.secho(f"✓ Added account \"{account}\"", fg="green")
@cli.command(name="list_remove", hidden=True)
@click.argument("title", required=False)
@click.argument("account")
@click.option("--id", help="List ID if not title is given")
@pass_context
def list_remove(ctx: Context, title: str, account: str, id: str):
"""Remove an account from a list"""
print_warning("`toot list_remove` is deprecated in favour of `toot lists remove`")
list_id = _get_list_id(ctx, title, id)
found_account = api.find_account(ctx.app, ctx.user, account)
api.remove_accounts_from_list(ctx.app, ctx.user, list_id, [found_account["id"]])
click.secho(f"✓ Removed account \"{account}\"", fg="green")
def _get_list_id(ctx: Context, title, list_id):
if not list_id and not title:
raise click.ClickException("Please specify list title or ID")
lists = api.get_lists(ctx.app, ctx.user)
matched_ids = [
list["id"] for list in lists
if list["title"].lower() == title.lower() or list["id"] == list_id
]
if not matched_ids:
raise click.ClickException("List not found")
if len(matched_ids) > 1:
raise click.ClickException("Found multiple lists with the same title, please specify the ID instead")
return matched_ids[0]

293
toot/cli/post.py 100644
Wyświetl plik

@ -0,0 +1,293 @@
import click
import os
import sys
from datetime import datetime, timedelta, timezone
from time import sleep, time
from typing import BinaryIO, Optional, Tuple
from toot import api, config
from toot.cli import AccountParamType, cli, json_option, pass_context, Context
from toot.cli import DURATION_EXAMPLES, VISIBILITY_CHOICES
from toot.cli.validators import validate_duration, validate_language
from toot.entities import MediaAttachment, from_dict
from toot.utils import EOF_KEY, delete_tmp_status_file, editor_input, multiline_input
from toot.utils.datetime import parse_datetime
@cli.command()
@click.argument("text", required=False)
@click.option(
"--media", "-m",
help="""Path to media file to attach, can be used multiple times to attach
multiple files.""",
type=click.File(mode="rb"),
multiple=True
)
@click.option(
"--description", "-d", "descriptions",
help="""Plain-text description of the media for accessibility purposes, one
per attached media""",
multiple=True,
)
@click.option(
"--thumbnail", "thumbnails",
help="Path to an image file to serve as media thumbnail, one per attached media",
type=click.File(mode="rb"),
multiple=True
)
@click.option(
"--visibility", "-v",
help="Post visibility",
type=click.Choice(VISIBILITY_CHOICES),
)
@click.option(
"--sensitive", "-s",
help="Mark status and attached media as sensitive",
default=False,
is_flag=True,
)
@click.option(
"--spoiler-text", "-p",
help="Text to be shown as a warning or subject before the actual content.",
)
@click.option(
"--reply-to", "-r",
help="ID of the status being replied to, if status is a reply.",
)
@click.option(
"--language", "-l",
help="ISO 639-1 language code of the toot, to skip automatic detection.",
callback=validate_language,
)
@click.option(
"--editor", "-e",
is_flag=False,
flag_value=os.getenv("EDITOR"),
help="""Specify an editor to compose your toot. When used without a value
it will use the editor defined in the $EDITOR environment variable.""",
)
@click.option(
"--scheduled-at",
help="""ISO 8601 Datetime at which to schedule a status. Must be at least 5
minutes in the future.""",
)
@click.option(
"--scheduled-in",
help=f"""Schedule the toot to be posted after a given amount of time,
{DURATION_EXAMPLES}. Must be at least 5 minutes.""",
callback=validate_duration,
)
@click.option(
"--content-type", "-t",
help="MIME type for the status text (not supported on all instances)",
)
@click.option(
"--poll-option",
help="Possible answer to the poll, can be given multiple times.",
multiple=True,
)
@click.option(
"--poll-expires-in",
help=f"Duration that the poll should be open, {DURATION_EXAMPLES}",
callback=validate_duration,
default="24h",
)
@click.option(
"--poll-multiple",
help="Allow multiple answers to be selected.",
is_flag=True,
default=False,
)
@click.option(
"--poll-hide-totals",
help="Hide vote counts until the poll ends.",
is_flag=True,
default=False,
)
@click.option(
"-u", "--using",
type=AccountParamType(),
help="The account to use, overrides the active account.",
)
@json_option
@pass_context
def post(
ctx: Context,
text: Optional[str],
media: Tuple[str],
descriptions: Tuple[str],
thumbnails: Tuple[str],
visibility: Optional[str],
sensitive: bool,
spoiler_text: Optional[str],
reply_to: Optional[str],
language: Optional[str],
editor: Optional[str],
scheduled_at: Optional[str],
scheduled_in: Optional[int],
content_type: Optional[str],
poll_option: Tuple[str],
poll_expires_in: int,
poll_multiple: bool,
poll_hide_totals: bool,
json: bool,
using: str
):
"""Post a new status"""
if len(media) > 4:
raise click.ClickException("Cannot attach more than 4 files.")
if using:
user, app = config.get_user_app(using)
if not user or not app:
raise click.ClickException(f"Account '{using}' not found. Run `toot auth` to see available accounts.")
else:
user, app = ctx.user, ctx.app
media_ids = _upload_media(app, user, media, descriptions, thumbnails)
status_text = _get_status_text(text, editor, media)
scheduled_at = _get_scheduled_at(scheduled_at, scheduled_in)
if not status_text and not media_ids:
raise click.ClickException("You must specify either text or media to post.")
response = api.post_status(
app,
user,
status_text,
visibility=visibility,
media_ids=media_ids,
sensitive=sensitive,
spoiler_text=spoiler_text,
in_reply_to_id=reply_to,
language=language,
scheduled_at=scheduled_at,
content_type=content_type,
poll_options=poll_option,
poll_expires_in=poll_expires_in,
poll_multiple=poll_multiple,
poll_hide_totals=poll_hide_totals,
)
if json:
click.echo(response.text)
else:
status = response.json()
if "scheduled_at" in status:
scheduled_at = parse_datetime(status["scheduled_at"])
scheduled_at = datetime.strftime(scheduled_at, "%Y-%m-%d %H:%M:%S%z")
click.echo(f"Toot scheduled for: {scheduled_at}")
else:
click.echo(f"Toot posted: {status['url']}")
delete_tmp_status_file()
@cli.command()
@click.argument("file", type=click.File(mode="rb"))
@click.option(
"--description", "-d",
help="Plain-text description of the media for accessibility purposes"
)
@json_option
@pass_context
def upload(
ctx: Context,
file: BinaryIO,
description: Optional[str],
json: bool,
):
"""Upload an image or video file
This is probably not very useful, see `toot post --media` instead.
"""
response = _do_upload(ctx.app, ctx.user, file, description, None)
if json:
click.echo(response.text)
else:
media = from_dict(MediaAttachment, response.json())
click.echo()
click.echo(f"Successfully uploaded media ID {media.id}, type '{media.type}'")
click.echo(f"URL: {media.url}")
click.echo(f"Preview URL: {media.preview_url}")
def _get_status_text(text, editor, media):
isatty = sys.stdin.isatty()
if not text and not isatty:
text = sys.stdin.read().rstrip()
if isatty:
if editor:
text = editor_input(editor, text)
elif not text and not media:
click.echo(f"Write or paste your toot. Press {EOF_KEY} to post it.")
text = multiline_input()
return text
def _get_scheduled_at(scheduled_at, scheduled_in):
if scheduled_at:
return scheduled_at
if scheduled_in:
scheduled_at = datetime.now(timezone.utc) + timedelta(seconds=scheduled_in)
return scheduled_at.replace(microsecond=0).isoformat()
return None
def _upload_media(app, user, media, descriptions, thumbnails):
# Match media to corresponding descriptions and thumbnail
media = media or []
descriptions = descriptions or []
thumbnails = thumbnails or []
uploaded_media = []
for idx, file in enumerate(media):
description = descriptions[idx].strip() if idx < len(descriptions) else None
thumbnail = thumbnails[idx] if idx < len(thumbnails) else None
result = _do_upload(app, user, file, description, thumbnail).json()
uploaded_media.append(result)
_wait_until_all_processed(app, user, uploaded_media)
return [m["id"] for m in uploaded_media]
def _do_upload(app, user, file, description, thumbnail):
return api.upload_media(app, user, file, description=description, thumbnail=thumbnail)
def _wait_until_all_processed(app, user, uploaded_media):
"""
Media is uploaded asynchronously, and cannot be attached until the server
has finished processing it. This function waits for that to happen.
Once media is processed, it will have the URL populated.
"""
if all(m["url"] for m in uploaded_media):
return
# Timeout after waiting 1 minute
start_time = time()
timeout = 60
click.echo("Waiting for media to finish processing...")
for media in uploaded_media:
_wait_until_processed(app, user, media, start_time, timeout)
def _wait_until_processed(app, user, media, start_time, timeout):
if media["url"]:
return
media = api.get_media(app, user, media["id"])
while not media["url"]:
sleep(1)
if time() > start_time + timeout:
raise click.ClickException(f"Media not processed by server after {timeout} seconds. Aborting.")
media = api.get_media(app, user, media["id"])

117
toot/cli/read.py 100644
Wyświetl plik

@ -0,0 +1,117 @@
import click
import json as pyjson
from itertools import chain
from typing import Optional
from toot import api
from toot.cli.validators import validate_instance
from toot.entities import Instance, Status, from_dict, Account
from toot.exceptions import ApiError, ConsoleError
from toot.output import print_account, print_instance, print_search_results, print_status, print_timeline
from toot.cli import InstanceParamType, cli, get_context, json_option, pass_context, Context
@cli.command()
@json_option
@pass_context
def whoami(ctx: Context, json: bool):
"""Display logged in user details"""
response = api.verify_credentials(ctx.app, ctx.user)
if json:
click.echo(response.text)
else:
account = from_dict(Account, response.json())
print_account(account)
@cli.command()
@click.argument("account")
@json_option
@pass_context
def whois(ctx: Context, account: str, json: bool):
"""Display account details"""
account_dict = api.find_account(ctx.app, ctx.user, account)
# Here it's not possible to avoid parsing json since it's needed to find the account.
if json:
click.echo(pyjson.dumps(account_dict))
else:
account_obj = from_dict(Account, account_dict)
print_account(account_obj)
@cli.command()
@click.argument("instance", type=InstanceParamType(), callback=validate_instance, required=False)
@json_option
def instance(instance: Optional[str], json: bool):
"""Display instance details
INSTANCE can be a domain or base URL of the instance to display.
e.g. 'mastodon.social' or 'https://mastodon.social'. If not
given will display details for the currently logged in instance.
"""
if not instance:
context = get_context()
if not context.app:
raise click.ClickException("INSTANCE argument not given and not logged in")
instance = context.app.base_url
try:
response = api.get_instance(instance)
except ApiError:
raise ConsoleError(
f"Instance not found at {instance}.\n" +
"The given domain probably does not host a Mastodon instance."
)
if json:
click.echo(response.text)
else:
print_instance(from_dict(Instance, response.json()))
@cli.command()
@click.argument("query")
@click.option("-r", "--resolve", is_flag=True, help="Resolve non-local accounts")
@json_option
@pass_context
def search(ctx: Context, query: str, resolve: bool, json: bool):
"""Search for users or hashtags"""
response = api.search(ctx.app, ctx.user, query, resolve)
if json:
click.echo(response.text)
else:
print_search_results(response.json())
@cli.command()
@click.argument("status_id")
@json_option
@pass_context
def status(ctx: Context, status_id: str, json: bool):
"""Show a single status"""
response = api.fetch_status(ctx.app, ctx.user, status_id)
if json:
click.echo(response.text)
else:
status = from_dict(Status, response.json())
print_status(status)
@cli.command()
@click.argument("status_id")
@json_option
@pass_context
def thread(ctx: Context, status_id: str, json: bool):
"""Show thread for a toot."""
context_response = api.context(ctx.app, ctx.user, status_id)
if json:
click.echo(context_response.text)
else:
toot = api.fetch_status(ctx.app, ctx.user, status_id).json()
context = context_response.json()
statuses = chain(context["ancestors"], [toot], context["descendants"])
print_timeline(from_dict(Status, s) for s in statuses)

Wyświetl plik

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

163
toot/cli/tags.py 100644
Wyświetl plik

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

Wyświetl plik

@ -0,0 +1,187 @@
import sys
import click
from toot import api
from toot.cli import InstanceParamType, cli, get_context, pass_context, Context
from typing import Optional
from toot.cli.validators import validate_instance
from toot.entities import Notification, Status, from_dict
from toot.output import print_notifications, print_timeline
@cli.command()
@click.option(
"--instance", "-i",
type=InstanceParamType(),
callback=validate_instance,
help="""Domain or base URL of the instance from which to read,
e.g. 'mastodon.social' or 'https://mastodon.social'""",
)
@click.option("--account", "-a", help="Show account timeline")
@click.option("--list", help="Show list timeline")
@click.option("--tag", "-t", help="Show hashtag timeline")
@click.option("--public", "-p", is_flag=True, help="Show public timeline")
@click.option(
"--local", "-l", is_flag=True,
help="Show only statuses from the local instance (public and tag timelines only)"
)
@click.option(
"--reverse", "-r", is_flag=True,
help="Reverse the order of the shown timeline (new posts at the bottom)"
)
@click.option(
"--once", "-1", is_flag=True,
help="Only show the first <count> toots, do not prompt to continue"
)
@click.option(
"--count", "-c", type=int, default=10,
help="Number of posts per page (max 20)"
)
def timeline(
instance: Optional[str],
account: Optional[str],
list: Optional[str],
tag: Optional[str],
public: bool,
local: bool,
reverse: bool,
once: bool,
count: int,
):
"""Show recent items in a timeline
By default shows the home timeline.
"""
if len([arg for arg in [tag, list, public, account] if arg]) > 1:
raise click.ClickException("Only one of --public, --tag, --account, or --list can be used at one time.")
if local and not (public or tag):
raise click.ClickException("The --local option is only valid alongside --public or --tag.")
if instance and not (public or tag):
raise click.ClickException("The --instance option is only valid alongside --public or --tag.")
if public and instance:
generator = api.anon_public_timeline_generator(instance, local, count)
elif tag and instance:
generator = api.anon_tag_timeline_generator(instance, tag, local, count)
else:
ctx = get_context()
list_id = _get_list_id(ctx, list)
"""Show recent statuses in a timeline"""
generator = api.get_timeline_generator(
ctx.app,
ctx.user,
account=account,
list_id=list_id,
tag=tag,
public=public,
local=local,
limit=count,
)
_show_timeline(generator, reverse, once)
@cli.command()
@click.option(
"--reverse", "-r", is_flag=True,
help="Reverse the order of the shown timeline (new posts at the bottom)"
)
@click.option(
"--once", "-1", is_flag=True,
help="Only show the first <count> toots, do not prompt to continue"
)
@click.option(
"--count", "-c", type=int, default=10,
help="Number of posts per page (max 20)"
)
@pass_context
def bookmarks(
ctx: Context,
reverse: bool,
once: bool,
count: int,
):
"""Show recent statuses in a timeline"""
generator = api.bookmark_timeline_generator(ctx.app, ctx.user, limit=count)
_show_timeline(generator, reverse, once)
@cli.command()
@click.option(
"--clear", 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)"
)
@click.option(
"--mentions", "-m", is_flag=True,
help="Show only mentions"
)
@pass_context
def notifications(
ctx: Context,
clear: bool,
reverse: bool,
mentions: int,
):
"""Show notifications"""
if clear:
api.clear_notifications(ctx.app, ctx.user)
click.secho("✓ Notifications cleared", fg="green")
return
exclude = []
if mentions:
# Filter everything except mentions
# https://docs.joinmastodon.org/methods/notifications/
exclude = ["follow", "favourite", "reblog", "poll", "follow_request"]
notifications = api.get_notifications(ctx.app, ctx.user, exclude_types=exclude)
if not notifications:
click.echo("You have no notifications")
return
if reverse:
notifications = reversed(notifications)
notifications = [from_dict(Notification, n) for n in notifications]
print_notifications(notifications)
def _show_timeline(generator, reverse, once):
while True:
try:
items = next(generator)
except StopIteration:
click.echo("That's all folks.")
return
if reverse:
items = reversed(items)
statuses = [from_dict(Status, item) for item in items]
print_timeline(statuses)
if once or not sys.stdout.isatty():
break
char = input("\nContinue? [Y/n] ")
if char.lower() == "n":
break
def _get_list_id(ctx: Context, value: Optional[str]) -> Optional[str]:
if not value:
return None
lists = api.get_lists(ctx.app, ctx.user)
for list in lists:
if list["id"] == value or list["title"] == value:
return list["id"]

73
toot/cli/tui.py 100644
Wyświetl plik

@ -0,0 +1,73 @@
import click
from typing import Optional
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())
@cli.command()
@click.option(
"-r", "--relative-datetimes",
is_flag=True,
help="Show relative datetimes in status list"
)
@click.option(
"-m", "--media-viewer",
help="Program to invoke with media URLs to display the media files, such as 'feh'"
)
@click.option(
"-c", "--colors",
callback=validate_tui_colors,
help=f"""Number of colors to use, one of {COLOR_OPTIONS}, defaults to 16 if
using --color, and 1 if using --no-color."""
)
@click.option(
"-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:
colors = 16 if ctx.color else 1
options = TuiOptions(
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

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

@ -1,571 +0,0 @@
import sys
import platform
from datetime import datetime, timedelta, timezone
from time import sleep, time
from toot import api, config, __version__
from toot.auth import login_interactive, login_browser_interactive, create_app_interactive
from toot.entities import Instance, Notification, Status, from_dict
from toot.exceptions import ApiError, ConsoleError
from toot.output import (print_lists, print_out, print_instance, print_account, print_acct_list,
print_search_results, print_status, print_timeline, print_notifications, print_tag_list,
print_list_accounts, print_user_list)
from toot.utils import args_get_instance, delete_tmp_status_file, editor_input, multiline_input, EOF_KEY
from toot.utils.datetime import parse_datetime
def get_timeline_generator(app, user, args):
if len([arg for arg in [args.tag, args.list, args.public, args.account] if arg]) > 1:
raise ConsoleError("Only one of --public, --tag, --account, or --list can be used at one time.")
if args.local and not (args.public or args.tag):
raise ConsoleError("The --local option is only valid alongside --public or --tag.")
if args.instance and not (args.public or args.tag):
raise ConsoleError("The --instance option is only valid alongside --public or --tag.")
if args.public:
if args.instance:
return api.anon_public_timeline_generator(args.instance, local=args.local, limit=args.count)
else:
return api.public_timeline_generator(app, user, local=args.local, limit=args.count)
elif args.tag:
if args.instance:
return api.anon_tag_timeline_generator(args.instance, args.tag, limit=args.count)
else:
return api.tag_timeline_generator(app, user, args.tag, local=args.local, limit=args.count)
elif args.account:
return api.account_timeline_generator(app, user, args.account, limit=args.count)
elif args.list:
return api.timeline_list_generator(app, user, args.list, limit=args.count)
else:
return api.home_timeline_generator(app, user, limit=args.count)
def timeline(app, user, args, generator=None):
if not generator:
generator = get_timeline_generator(app, user, args)
while True:
try:
items = next(generator)
except StopIteration:
print_out("That's all folks.")
return
if args.reverse:
items = reversed(items)
statuses = [from_dict(Status, item) for item in items]
print_timeline(statuses)
if args.once or not sys.stdout.isatty():
break
char = input("\nContinue? [Y/n] ")
if char.lower() == "n":
break
def status(app, user, args):
status = api.single_status(app, user, args.status_id)
status = from_dict(Status, status)
print_status(status)
def thread(app, user, args):
toot = api.single_status(app, user, args.status_id)
context = api.context(app, user, args.status_id)
thread = []
for item in context['ancestors']:
thread.append(item)
thread.append(toot)
for item in context['descendants']:
thread.append(item)
statuses = [from_dict(Status, s) for s in thread]
print_timeline(statuses)
def post(app, user, args):
if args.editor and not sys.stdin.isatty():
raise ConsoleError("Cannot run editor if not in tty.")
if args.media and len(args.media) > 4:
raise ConsoleError("Cannot attach more than 4 files.")
media_ids = _upload_media(app, user, args)
status_text = _get_status_text(args.text, args.editor, args.media)
scheduled_at = _get_scheduled_at(args.scheduled_at, args.scheduled_in)
if not status_text and not media_ids:
raise ConsoleError("You must specify either text or media to post.")
response = api.post_status(
app, user, status_text,
visibility=args.visibility,
media_ids=media_ids,
sensitive=args.sensitive,
spoiler_text=args.spoiler_text,
in_reply_to_id=args.reply_to,
language=args.language,
scheduled_at=scheduled_at,
content_type=args.content_type,
poll_options=args.poll_option,
poll_expires_in=args.poll_expires_in,
poll_multiple=args.poll_multiple,
poll_hide_totals=args.poll_hide_totals,
)
if "scheduled_at" in response:
scheduled_at = parse_datetime(response["scheduled_at"])
scheduled_at = datetime.strftime(scheduled_at, "%Y-%m-%d %H:%M:%S%z")
print_out(f"Toot scheduled for: <green>{scheduled_at}</green>")
else:
print_out(f"Toot posted: <green>{response['url']}")
delete_tmp_status_file()
def _get_status_text(text, editor, media):
isatty = sys.stdin.isatty()
if not text and not isatty:
text = sys.stdin.read().rstrip()
if isatty:
if editor:
text = editor_input(editor, text)
elif not text and not media:
print_out("Write or paste your toot. Press <yellow>{}</yellow> to post it.".format(EOF_KEY))
text = multiline_input()
return text
def _get_scheduled_at(scheduled_at, scheduled_in):
if scheduled_at:
return scheduled_at
if scheduled_in:
scheduled_at = datetime.now(timezone.utc) + timedelta(seconds=scheduled_in)
return scheduled_at.replace(microsecond=0).isoformat()
return None
def _upload_media(app, user, args):
# Match media to corresponding description and thumbnail
media = args.media or []
descriptions = args.description or []
thumbnails = args.thumbnail or []
uploaded_media = []
for idx, file in enumerate(media):
description = descriptions[idx].strip() if idx < len(descriptions) else None
thumbnail = thumbnails[idx] if idx < len(thumbnails) else None
result = _do_upload(app, user, file, description, thumbnail)
uploaded_media.append(result)
_wait_until_all_processed(app, user, uploaded_media)
return [m["id"] for m in uploaded_media]
def _wait_until_all_processed(app, user, uploaded_media):
"""
Media is uploaded asynchronously, and cannot be attached until the server
has finished processing it. This function waits for that to happen.
Once media is processed, it will have the URL populated.
"""
if all(m["url"] for m in uploaded_media):
return
# Timeout after waiting 1 minute
start_time = time()
timeout = 60
print_out("<dim>Waiting for media to finish processing...</dim>")
for media in uploaded_media:
_wait_until_processed(app, user, media, start_time, timeout)
def _wait_until_processed(app, user, media, start_time, timeout):
if media["url"]:
return
media = api.get_media(app, user, media["id"])
while not media["url"]:
sleep(1)
if time() > start_time + timeout:
raise ConsoleError(f"Media not processed by server after {timeout} seconds. Aborting.")
media = api.get_media(app, user, media["id"])
def delete(app, user, args):
api.delete_status(app, user, args.status_id)
print_out("<green>✓ Status deleted</green>")
def favourite(app, user, args):
api.favourite(app, user, args.status_id)
print_out("<green>✓ Status favourited</green>")
def unfavourite(app, user, args):
api.unfavourite(app, user, args.status_id)
print_out("<green>✓ Status unfavourited</green>")
def reblog(app, user, args):
api.reblog(app, user, args.status_id, visibility=args.visibility)
print_out("<green>✓ Status reblogged</green>")
def unreblog(app, user, args):
api.unreblog(app, user, args.status_id)
print_out("<green>✓ Status unreblogged</green>")
def pin(app, user, args):
api.pin(app, user, args.status_id)
print_out("<green>✓ Status pinned</green>")
def unpin(app, user, args):
api.unpin(app, user, args.status_id)
print_out("<green>✓ Status unpinned</green>")
def bookmark(app, user, args):
api.bookmark(app, user, args.status_id)
print_out("<green>✓ Status bookmarked</green>")
def unbookmark(app, user, args):
api.unbookmark(app, user, args.status_id)
print_out("<green>✓ Status unbookmarked</green>")
def bookmarks(app, user, args):
timeline(app, user, args, api.bookmark_timeline_generator(app, user, limit=args.count))
def reblogged_by(app, user, args):
for account in api.reblogged_by(app, user, args.status_id):
print_out("{}\n @{}".format(account['display_name'], account['acct']))
def auth(app, user, args):
config_data = config.load_config()
if not config_data["users"]:
print_out("You are not logged in to any accounts")
return
active_user = config_data["active_user"]
print_out("Authenticated accounts:")
for uid, u in config_data["users"].items():
active_label = "ACTIVE" if active_user == uid else ""
print_out("* <green>{}</green> <yellow>{}</yellow>".format(uid, active_label))
path = config.get_config_file_path()
print_out("\nAuth tokens are stored in: <blue>{}</blue>".format(path))
def env(app, user, args):
print_out(f"toot {__version__}")
print_out(f"Python {sys.version}")
print_out(platform.platform())
def update_account(app, user, args):
options = [
args.avatar,
args.bot,
args.discoverable,
args.display_name,
args.header,
args.language,
args.locked,
args.note,
args.privacy,
args.sensitive,
]
if all(option is None for option in options):
raise ConsoleError("Please specify at least one option to update the account")
api.update_account(
app,
user,
avatar=args.avatar,
bot=args.bot,
discoverable=args.discoverable,
display_name=args.display_name,
header=args.header,
language=args.language,
locked=args.locked,
note=args.note,
privacy=args.privacy,
sensitive=args.sensitive,
)
print_out("<green>✓ Account updated</green>")
def login_cli(app, user, args):
base_url = args_get_instance(args.instance, args.scheme)
app = create_app_interactive(base_url)
login_interactive(app, args.email)
print_out()
print_out("<green>✓ Successfully logged in.</green>")
def login(app, user, args):
base_url = args_get_instance(args.instance, args.scheme)
app = create_app_interactive(base_url)
login_browser_interactive(app)
print_out()
print_out("<green>✓ Successfully logged in.</green>")
def logout(app, user, args):
user = config.load_user(args.account, throw=True)
config.delete_user(user)
print_out("<green>✓ User {} logged out</green>".format(config.user_id(user)))
def activate(app, user, args):
if not args.account:
print_out("Specify one of the following user accounts to activate:\n")
print_user_list(config.get_user_list())
return
user = config.load_user(args.account, throw=True)
config.activate_user(user)
print_out("<green>✓ User {} active</green>".format(config.user_id(user)))
def upload(app, user, args):
response = _do_upload(app, user, args.file, args.description, None)
msg = "Successfully uploaded media ID <yellow>{}</yellow>, type '<yellow>{}</yellow>'"
print_out()
print_out(msg.format(response['id'], response['type']))
print_out("URL: <green>{}</green>".format(response['url']))
print_out("Preview URL: <green>{}</green>".format(response['preview_url']))
def search(app, user, args):
response = api.search(app, user, args.query, args.resolve)
print_search_results(response)
def _do_upload(app, user, file, description, thumbnail):
print_out("Uploading media: <green>{}</green>".format(file.name))
return api.upload_media(app, user, file, description=description, thumbnail=thumbnail)
def follow(app, user, args):
account = api.find_account(app, user, args.account)
api.follow(app, user, account['id'])
print_out("<green>✓ You are now following {}</green>".format(args.account))
def unfollow(app, user, args):
account = api.find_account(app, user, args.account)
api.unfollow(app, user, account['id'])
print_out("<green>✓ You are no longer following {}</green>".format(args.account))
def following(app, user, args):
account = api.find_account(app, user, args.account)
response = api.following(app, user, account['id'])
print_acct_list(response)
def followers(app, user, args):
account = api.find_account(app, user, args.account)
response = api.followers(app, user, account['id'])
print_acct_list(response)
def tags_follow(app, user, args):
tn = args.tag_name if not args.tag_name.startswith("#") else args.tag_name[1:]
api.follow_tag(app, user, tn)
print_out("<green>✓ You are now following #{}</green>".format(tn))
def tags_unfollow(app, user, args):
tn = args.tag_name if not args.tag_name.startswith("#") else args.tag_name[1:]
api.unfollow_tag(app, user, tn)
print_out("<green>✓ You are no longer following #{}</green>".format(tn))
def tags_followed(app, user, args):
response = api.followed_tags(app, user)
print_tag_list(response)
def lists(app, user, args):
lists = api.get_lists(app, user)
if lists:
print_lists(lists)
else:
print_out("You have no lists defined.")
def list_accounts(app, user, args):
list_id = _get_list_id(app, user, args)
response = api.get_list_accounts(app, user, list_id)
print_list_accounts(response)
def list_create(app, user, args):
api.create_list(app, user, title=args.title, replies_policy=args.replies_policy)
print_out(f"<green>✓ List \"{args.title}\" created.</green>")
def list_delete(app, user, args):
list_id = _get_list_id(app, user, args)
api.delete_list(app, user, list_id)
print_out(f"<green>✓ List \"{args.title if args.title else args.id}\"</green> <red>deleted.</red>")
def list_add(app, user, args):
list_id = _get_list_id(app, user, args)
account = api.find_account(app, user, args.account)
try:
api.add_accounts_to_list(app, user, list_id, [account['id']])
except Exception as ex:
# if we failed to add the account, try to give a
# more specific error message than "record not found"
my_accounts = api.followers(app, user, account['id'])
found = False
if my_accounts:
for my_account in my_accounts:
if my_account['id'] == account['id']:
found = True
break
if found is False:
print_out(f"<red>You must follow @{account['acct']} before adding this account to a list.</red>")
else:
print_out(f"<red>{ex}</red>")
return
print_out(f"<green>✓ Added account \"{args.account}\"</green>")
def list_remove(app, user, args):
list_id = _get_list_id(app, user, args)
account = api.find_account(app, user, args.account)
api.remove_accounts_from_list(app, user, list_id, [account['id']])
print_out(f"<green>✓ Removed account \"{args.account}\"</green>")
def _get_list_id(app, user, args):
list_id = args.id or api.find_list_id(app, user, args.title)
if not list_id:
raise ConsoleError("List not found")
return list_id
def mute(app, user, args):
account = api.find_account(app, user, args.account)
api.mute(app, user, account['id'])
print_out("<green>✓ You have muted {}</green>".format(args.account))
def unmute(app, user, args):
account = api.find_account(app, user, args.account)
api.unmute(app, user, account['id'])
print_out("<green>✓ {} is no longer muted</green>".format(args.account))
def muted(app, user, args):
response = api.muted(app, user)
print_acct_list(response)
def block(app, user, args):
account = api.find_account(app, user, args.account)
api.block(app, user, account['id'])
print_out("<green>✓ You are now blocking {}</green>".format(args.account))
def unblock(app, user, args):
account = api.find_account(app, user, args.account)
api.unblock(app, user, account['id'])
print_out("<green>✓ {} is no longer blocked</green>".format(args.account))
def blocked(app, user, args):
response = api.blocked(app, user)
print_acct_list(response)
def whoami(app, user, args):
account = api.verify_credentials(app, user)
print_account(account)
def whois(app, user, args):
account = api.find_account(app, user, args.account)
print_account(account)
def instance(app, user, args):
default = app.base_url if app else None
base_url = args_get_instance(args.instance, args.scheme, default)
if not base_url:
raise ConsoleError("Please specify an instance.")
try:
instance = api.get_instance(base_url)
instance = from_dict(Instance, instance)
print_instance(instance)
except ApiError:
raise ConsoleError(
f"Instance not found at {base_url}.\n"
"The given domain probably does not host a Mastodon instance."
)
def notifications(app, user, args):
if args.clear:
api.clear_notifications(app, user)
print_out("<green>Cleared notifications</green>")
return
exclude = []
if args.mentions:
# Filter everything except mentions
# https://docs.joinmastodon.org/methods/notifications/
exclude = ["follow", "favourite", "reblog", "poll", "follow_request"]
notifications = api.get_notifications(app, user, exclude_types=exclude)
if not notifications:
print_out("<yellow>No notification</yellow>")
return
if args.reverse:
notifications = reversed(notifications)
notifications = [from_dict(Notification, n) for n in notifications]
print_notifications(notifications)
def tui(app, user, args):
from .tui.app import TUI
TUI.create(app, user, args).run()

Wyświetl plik

@ -1,12 +1,12 @@
import json
import os
from functools import wraps
from contextlib import contextmanager
from os.path import dirname, join
from typing import Optional
from toot import User, App, get_config_dir
from toot.exceptions import ConsoleError
from toot.output import print_out
TOOT_CONFIG_FILE_NAME = "config.json"
@ -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": {},
@ -29,8 +29,6 @@ def make_config(path):
"active_user": None,
}
print_out("Creating config file at <blue>{}</blue>".format(path))
# Ensure dir exists
os.makedirs(dirname(path), exist_ok=True)
@ -41,6 +39,10 @@ def make_config(path):
def load_config():
# Just to prevent accidentally running tests on production
if os.environ.get("TOOT_TESTING"):
raise Exception("Tests should not access the config file!")
path = get_config_file_path()
if not os.path.exists(path):
@ -56,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
@ -80,18 +82,18 @@ 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)
def load_app(instance):
def load_app(instance: str) -> Optional[App]:
config = load_config()
if instance in config['apps']:
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']:
@ -106,63 +108,39 @@ def get_user_list():
return config['users']
def modify_config(f):
@wraps(f)
def wrapper(*args, **kwargs):
config = load_config()
config = f(config, *args, **kwargs)
save_config(config)
return config
return wrapper
@contextmanager
def edit_config():
config = load_config()
yield config
save_config(config)
@modify_config
def save_app(config, app):
assert isinstance(app, App)
config['apps'][app.instance] = app._asdict()
return config
def save_app(app: App):
with edit_config() as config:
config['apps'][app.instance] = app._asdict()
@modify_config
def delete_app(config, app):
assert isinstance(app, App)
config['apps'].pop(app.instance, None)
return config
def delete_app(config, app: App):
with edit_config() as config:
config['apps'].pop(app.instance, None)
@modify_config
def save_user(config, user, activate=True):
assert isinstance(user, User)
def save_user(user: User, activate=True):
with edit_config() as config:
config['users'][user_id(user)] = user._asdict()
config['users'][user_id(user)] = user._asdict()
if activate:
config['active_user'] = user_id(user)
if activate:
def delete_user(user: User):
with edit_config() as config:
config['users'].pop(user_id(user), None)
if config['active_user'] == user_id(user):
config['active_user'] = None
def activate_user(user: User):
with edit_config() as config:
config['active_user'] = user_id(user)
return config
@modify_config
def delete_user(config, user):
assert isinstance(user, User)
config['users'].pop(user_id(user), None)
if config['active_user'] == user_id(user):
config['active_user'] = None
return config
@modify_config
def activate_user(config, user):
assert isinstance(user, User)
config['active_user'] = user_id(user)
return config

Wyświetl plik

@ -1,966 +0,0 @@
import logging
import os
import re
import shutil
import sys
from argparse import ArgumentParser, FileType, ArgumentTypeError, Action
from collections import namedtuple
from itertools import chain
from toot import config, commands, CLIENT_NAME, CLIENT_WEBSITE, __version__, settings
from toot.exceptions import ApiError, ConsoleError
from toot.output import print_out, print_err
from toot.settings import get_setting
VISIBILITY_CHOICES = ["public", "unlisted", "private", "direct"]
VISIBILITY_CHOICES_STR = ", ".join(f"'{v}'" for v in VISIBILITY_CHOICES)
PRIVACY_CHOICES = ["public", "unlisted", "private"]
PRIVACY_CHOICES_STR = ", ".join(f"'{v}'" for v in PRIVACY_CHOICES)
class BooleanOptionalAction(Action):
"""
Backported from argparse. This action is available since Python 3.9.
https://github.com/python/cpython/blob/3.11/Lib/argparse.py
"""
def __init__(self,
option_strings,
dest,
default=None,
type=None,
choices=None,
required=False,
help=None,
metavar=None):
_option_strings = []
for option_string in option_strings:
_option_strings.append(option_string)
if option_string.startswith('--'):
option_string = '--no-' + option_string[2:]
_option_strings.append(option_string)
super().__init__(
option_strings=_option_strings,
dest=dest,
nargs=0,
default=default,
type=type,
choices=choices,
required=required,
help=help,
metavar=metavar)
def __call__(self, parser, namespace, values, option_string=None):
if option_string in self.option_strings:
setattr(namespace, self.dest, not option_string.startswith('--no-'))
def format_usage(self):
return ' | '.join(self.option_strings)
def get_default_visibility():
return os.getenv("TOOT_POST_VISIBILITY", "public")
def language(value):
"""Validates the language parameter"""
if len(value) != 2:
raise ArgumentTypeError(
"Invalid language. Expected a 2 letter abbreviation according to "
"the ISO 639-1 standard."
)
return value
def visibility(value):
"""Validates the visibility parameter"""
if value not in VISIBILITY_CHOICES:
raise ValueError("Invalid visibility value")
return value
def privacy(value):
"""Validates the privacy parameter"""
if value not in PRIVACY_CHOICES:
raise ValueError(f"Invalid privacy value. Expected one of {PRIVACY_CHOICES_STR}.")
return value
def timeline_count(value):
n = int(value)
if not 0 < n <= 20:
raise ArgumentTypeError("Number of toots should be between 1 and 20.")
return n
DURATION_UNITS = {
"m": 60,
"h": 60 * 60,
"d": 60 * 60 * 24,
}
DURATION_EXAMPLES = """e.g. "1 day", "2 hours 30 minutes", "5 minutes 30
seconds" or any combination of above. Shorthand: "1d", "2h30m", "5m30s\""""
def duration(value: str):
match = re.match(r"""^
(([0-9]+)\s*(days|day|d))?\s*
(([0-9]+)\s*(hours|hour|h))?\s*
(([0-9]+)\s*(minutes|minute|m))?\s*
(([0-9]+)\s*(seconds|second|s))?\s*
$""", value, re.X)
if not match:
raise ArgumentTypeError(f"Invalid duration: {value}")
days = match.group(2)
hours = match.group(5)
minutes = match.group(8)
seconds = match.group(11)
days = int(match.group(2) or 0) * 60 * 60 * 24
hours = int(match.group(5) or 0) * 60 * 60
minutes = int(match.group(8) or 0) * 60
seconds = int(match.group(11) or 0)
duration = days + hours + minutes + seconds
if duration == 0:
raise ArgumentTypeError("Empty duration")
return duration
def editor(value):
if not value:
raise ArgumentTypeError(
"Editor not specified in --editor option and $EDITOR environment "
"variable not set."
)
# Check editor executable exists
exe = shutil.which(value)
if not exe:
raise ArgumentTypeError("Editor `{}` not found".format(value))
return exe
Command = namedtuple("Command", ["name", "description", "require_auth", "arguments"])
# Arguments added to every command
common_args = [
(["--no-color"], {
"help": "don't use ANSI colors in output",
"action": 'store_true',
"default": False,
}),
(["--quiet"], {
"help": "don't write to stdout on success",
"action": 'store_true',
"default": False,
}),
(["--debug"], {
"help": "show debug log in console",
"action": 'store_true',
"default": False,
}),
(["--verbose"], {
"help": "show extra detail in debug log; used with --debug",
"action": 'store_true',
"default": False,
}),
]
# Arguments added to commands which require authentication
common_auth_args = [
(["-u", "--using"], {
"help": "the account to use, overrides active account",
}),
]
account_arg = (["account"], {
"help": "account name, e.g. 'Gargron@mastodon.social'",
})
optional_account_arg = (["account"], {
"nargs": "?",
"help": "account name, e.g. 'Gargron@mastodon.social'",
})
instance_arg = (["-i", "--instance"], {
"type": str,
"help": 'mastodon instance to log into e.g. "mastodon.social"',
})
email_arg = (["-e", "--email"], {
"type": str,
"help": 'email address to log in with',
})
scheme_arg = (["--disable-https"], {
"help": "disable HTTPS and use insecure HTTP",
"dest": "scheme",
"default": "https",
"action": "store_const",
"const": "http",
})
status_id_arg = (["status_id"], {
"help": "ID of the status",
"type": str,
})
visibility_arg = (["-v", "--visibility"], {
"type": visibility,
"default": get_default_visibility(),
"help": f"Post visibility. One of: {VISIBILITY_CHOICES_STR}. Defaults to "
f"'{get_default_visibility()}' which can be overridden by setting "
"the TOOT_POST_VISIBILITY environment variable",
})
tag_arg = (["tag_name"], {
"type": str,
"help": "tag name, e.g. Caturday, or \"#Caturday\"",
})
# Arguments for selecting a timeline (see `toot.commands.get_timeline_generator`)
common_timeline_args = [
(["-p", "--public"], {
"action": "store_true",
"default": False,
"help": "show public timeline (does not require auth)",
}),
(["-t", "--tag"], {
"type": str,
"help": "show hashtag timeline (does not require auth)",
}),
(["-a", "--account"], {
"type": str,
"help": "show timeline for the given account",
}),
(["-l", "--local"], {
"action": "store_true",
"default": False,
"help": "show only statuses from local instance (public and tag timelines only)",
}),
(["-i", "--instance"], {
"type": str,
"help": "mastodon instance from which to read (public and tag timelines only)",
}),
(["--list"], {
"type": str,
"help": "show timeline for given list.",
}),
]
timeline_and_bookmark_args = [
(["-c", "--count"], {
"type": timeline_count,
"help": "number of toots to show per page (1-20, default 10).",
"default": 10,
}),
(["-r", "--reverse"], {
"action": "store_true",
"default": False,
"help": "Reverse the order of the shown timeline (to new posts at the bottom)",
}),
(["-1", "--once"], {
"action": "store_true",
"default": False,
"help": "Only show the first <count> toots, do not prompt to continue.",
}),
]
timeline_args = common_timeline_args + timeline_and_bookmark_args
AUTH_COMMANDS = [
Command(
name="login",
description="Log into a mastodon instance using your browser (recommended)",
arguments=[instance_arg, scheme_arg],
require_auth=False,
),
Command(
name="login_cli",
description="Log in from the console, does NOT support two factor authentication",
arguments=[instance_arg, email_arg, scheme_arg],
require_auth=False,
),
Command(
name="activate",
description="Switch between logged in accounts.",
arguments=[optional_account_arg],
require_auth=False,
),
Command(
name="logout",
description="Log out, delete stored access keys",
arguments=[account_arg],
require_auth=False,
),
Command(
name="auth",
description="Show logged in accounts and instances",
arguments=[],
require_auth=False,
),
Command(
name="env",
description="Print environment information for inclusion in bug reports.",
arguments=[],
require_auth=False,
),
Command(
name="update_account",
description="Update your account details",
arguments=[
(["--display-name"], {
"type": str,
"help": "The display name to use for the profile.",
}),
(["--note"], {
"type": str,
"help": "The account bio.",
}),
(["--avatar"], {
"type": FileType("rb"),
"help": "Path to the avatar image to set.",
}),
(["--header"], {
"type": FileType("rb"),
"help": "Path to the header image to set.",
}),
(["--bot"], {
"action": BooleanOptionalAction,
"help": "Whether the account has a bot flag.",
}),
(["--discoverable"], {
"action": BooleanOptionalAction,
"help": "Whether the account should be shown in the profile directory.",
}),
(["--locked"], {
"action": BooleanOptionalAction,
"help": "Whether manual approval of follow requests is required.",
}),
(["--privacy"], {
"type": privacy,
"help": f"Default post privacy for authored statuses. One of: {PRIVACY_CHOICES_STR}."
}),
(["--sensitive"], {
"action": BooleanOptionalAction,
"help": "Whether to mark authored statuses as sensitive by default."
}),
(["--language"], {
"type": language,
"help": "Default language to use for authored statuses (ISO 639-1)."
}),
],
require_auth=True,
),
]
TUI_COMMANDS = [
Command(
name="tui",
description="Launches the toot terminal user interface",
arguments=[
(["--relative-datetimes"], {
"action": "store_true",
"default": False,
"help": "Show relative datetimes in status list.",
}),
],
require_auth=True,
),
]
READ_COMMANDS = [
Command(
name="whoami",
description="Display logged in user details",
arguments=[],
require_auth=True,
),
Command(
name="whois",
description="Display account details",
arguments=[
(["account"], {
"help": "account name or numeric ID"
}),
],
require_auth=True,
),
Command(
name="notifications",
description="Notifications for logged in user",
arguments=[
(["--clear"], {
"help": "delete all notifications from the server",
"action": 'store_true',
"default": False,
}),
(["-r", "--reverse"], {
"action": "store_true",
"default": False,
"help": "Reverse the order of the shown notifications (newest on top)",
}),
(["-m", "--mentions"], {
"action": "store_true",
"default": False,
"help": "Only print mentions",
})
],
require_auth=True,
),
Command(
name="instance",
description="Display instance details",
arguments=[
(["instance"], {
"help": "instance domain (e.g. 'mastodon.social') or blank to use current",
"nargs": "?",
}),
scheme_arg,
],
require_auth=False,
),
Command(
name="search",
description="Search for users or hashtags",
arguments=[
(["query"], {
"help": "the search query",
}),
(["-r", "--resolve"], {
"action": 'store_true',
"default": False,
"help": "Resolve non-local accounts",
}),
],
require_auth=True,
),
Command(
name="thread",
description="Show toot thread items",
arguments=[
(["status_id"], {
"help": "Show thread for toot.",
}),
],
require_auth=True,
),
Command(
name="status",
description="Show a single status",
arguments=[
(["status_id"], {
"help": "ID of the status to show.",
}),
],
require_auth=True,
),
Command(
name="timeline",
description="Show recent items in a timeline (home by default)",
arguments=timeline_args,
require_auth=True,
),
Command(
name="bookmarks",
description="Show bookmarked posts",
arguments=timeline_and_bookmark_args,
require_auth=True,
),
]
POST_COMMANDS = [
Command(
name="post",
description="Post a status text to your timeline",
arguments=[
(["text"], {
"help": "The status text to post.",
"nargs": "?",
}),
(["-m", "--media"], {
"action": "append",
"type": FileType("rb"),
"help": "path to the media file to attach (specify multiple "
"times to attach up to 4 files)"
}),
(["-d", "--description"], {
"action": "append",
"type": str,
"help": "plain-text description of the media for accessibility "
"purposes, one per attached media"
}),
(["--thumbnail"], {
"action": "append",
"type": FileType("rb"),
"help": "path to an image file to serve as media thumbnail, "
"one per attached media"
}),
visibility_arg,
(["-s", "--sensitive"], {
"action": 'store_true',
"default": False,
"help": "mark the media as NSFW",
}),
(["-p", "--spoiler-text"], {
"type": str,
"help": "text to be shown as a warning before the actual content",
}),
(["-r", "--reply-to"], {
"type": str,
"help": "local ID of the status you want to reply to",
}),
(["-l", "--language"], {
"type": language,
"help": "ISO 639-1 language code of the toot, to skip automatic detection",
}),
(["-e", "--editor"], {
"type": editor,
"nargs": "?",
"const": os.getenv("EDITOR", ""), # option given without value
"help": "Specify an editor to compose your toot, "
"defaults to editor defined in $EDITOR env variable.",
}),
(["--scheduled-at"], {
"type": str,
"help": "ISO 8601 Datetime at which to schedule a status. Must "
"be at least 5 minutes in the future.",
}),
(["--scheduled-in"], {
"type": duration,
"help": f"""Schedule the toot to be posted after a given amount
of time, {DURATION_EXAMPLES}. Must be at least 5
minutes.""",
}),
(["-t", "--content-type"], {
"type": str,
"help": "MIME type for the status text (not supported on all instances)",
}),
(["--poll-option"], {
"action": "append",
"type": str,
"help": "Possible answer to the poll"
}),
(["--poll-expires-in"], {
"type": duration,
"help": f"""Duration that the poll should be open,
{DURATION_EXAMPLES}. Defaults to 24h.""",
"default": 24 * 60 * 60,
}),
(["--poll-multiple"], {
"action": "store_true",
"default": False,
"help": "Allow multiple answers to be selected."
}),
(["--poll-hide-totals"], {
"action": "store_true",
"default": False,
"help": "Hide vote counts until the poll ends. Defaults to false."
}),
],
require_auth=True,
),
Command(
name="upload",
description="Upload an image or video file",
arguments=[
(["file"], {
"help": "Path to the file to upload",
"type": FileType('rb')
}),
(["-d", "--description"], {
"type": str,
"help": "plain-text description of the media for accessibility purposes"
}),
],
require_auth=True,
),
]
STATUS_COMMANDS = [
Command(
name="delete",
description="Delete a status",
arguments=[status_id_arg],
require_auth=True,
),
Command(
name="favourite",
description="Favourite a status",
arguments=[status_id_arg],
require_auth=True,
),
Command(
name="unfavourite",
description="Unfavourite a status",
arguments=[status_id_arg],
require_auth=True,
),
Command(
name="reblog",
description="Reblog a status",
arguments=[status_id_arg, visibility_arg],
require_auth=True,
),
Command(
name="unreblog",
description="Unreblog a status",
arguments=[status_id_arg],
require_auth=True,
),
Command(
name="reblogged_by",
description="Show accounts that reblogged the status",
arguments=[status_id_arg],
require_auth=False,
),
Command(
name="pin",
description="Pin a status",
arguments=[status_id_arg],
require_auth=True,
),
Command(
name="unpin",
description="Unpin a status",
arguments=[status_id_arg],
require_auth=True,
),
Command(
name="bookmark",
description="Bookmark a status",
arguments=[status_id_arg],
require_auth=True,
),
Command(
name="unbookmark",
description="Unbookmark a status",
arguments=[status_id_arg],
require_auth=True,
),
]
ACCOUNTS_COMMANDS = [
Command(
name="follow",
description="Follow an account",
arguments=[
account_arg,
],
require_auth=True,
),
Command(
name="unfollow",
description="Unfollow an account",
arguments=[
account_arg,
],
require_auth=True,
),
Command(
name="following",
description="List accounts followed by the given account",
arguments=[
account_arg,
],
require_auth=True,
),
Command(
name="followers",
description="List accounts following the given account",
arguments=[
account_arg,
],
require_auth=True,
),
Command(
name="mute",
description="Mute an account",
arguments=[
account_arg,
],
require_auth=True,
),
Command(
name="unmute",
description="Unmute an account",
arguments=[
account_arg,
],
require_auth=True,
),
Command(
name="muted",
description="List muted accounts",
arguments=[],
require_auth=True,
),
Command(
name="block",
description="Block an account",
arguments=[
account_arg,
],
require_auth=True,
),
Command(
name="unblock",
description="Unblock an account",
arguments=[
account_arg,
],
require_auth=True,
),
Command(
name="blocked",
description="List blocked accounts",
arguments=[],
require_auth=True,
),
]
TAG_COMMANDS = [
Command(
name="tags_followed",
description="List hashtags you follow",
arguments=[],
require_auth=True,
),
Command(
name="tags_follow",
description="Follow a hashtag",
arguments=[tag_arg],
require_auth=True,
),
Command(
name="tags_unfollow",
description="Unfollow a hashtag",
arguments=[tag_arg],
require_auth=True,
),
]
LIST_COMMANDS = [
Command(
name="lists",
description="List all lists",
arguments=[],
require_auth=True,
),
Command(
name="list_accounts",
description="List the accounts in a list",
arguments=[
(["--id"], {
"type": str,
"help": "ID of the list"
}),
(["title"], {
"type": str,
"nargs": "?",
"help": "title of the list"
}),
],
require_auth=True,
),
Command(
name="list_create",
description="Create a list",
arguments=[
(["title"], {
"type": str,
"help": "title of the list"
}),
(["--replies-policy"], {
"type": str,
"help": "replies policy: 'followed', 'list', or 'none' (defaults to 'none')"
}),
],
require_auth=True,
),
Command(
name="list_delete",
description="Delete a list",
arguments=[
(["--id"], {
"type": str,
"help": "ID of the list"
}),
(["title"], {
"type": str,
"nargs": "?",
"help": "title of the list"
}),
],
require_auth=True,
),
Command(
name="list_add",
description="Add account to list",
arguments=[
(["--id"], {
"type": str,
"help": "ID of the list"
}),
(["title"], {
"type": str,
"nargs": "?",
"help": "title of the list"
}),
(["account"], {
"type": str,
"help": "Account to add"
}),
],
require_auth=True,
),
Command(
name="list_remove",
description="Remove account from list",
arguments=[
(["--id"], {
"type": str,
"help": "ID of the list"
}),
(["title"], {
"type": str,
"nargs": "?",
"help": "title of the list"
}),
(["account"], {
"type": str,
"help": "Account to remove"
}),
],
require_auth=True,
),
]
COMMAND_GROUPS = [
("Authentication", AUTH_COMMANDS),
("TUI", TUI_COMMANDS),
("Read", READ_COMMANDS),
("Post", POST_COMMANDS),
("Status", STATUS_COMMANDS),
("Accounts", ACCOUNTS_COMMANDS),
("Hashtags", TAG_COMMANDS),
("Lists", LIST_COMMANDS),
]
COMMANDS = list(chain(*[commands for _, commands in COMMAND_GROUPS]))
def print_usage():
max_name_len = max(len(name) for name, _ in COMMAND_GROUPS)
print_out("<green>{}</green>".format(CLIENT_NAME))
print_out("<blue>v{}</blue>".format(__version__))
for name, cmds in COMMAND_GROUPS:
print_out("")
print_out(name + ":")
for cmd in cmds:
cmd_name = cmd.name.ljust(max_name_len + 2)
print_out(" <yellow>toot {}</yellow> {}".format(cmd_name, cmd.description))
print_out("")
print_out("To get help for each command run:")
print_out(" <yellow>toot \\<command> --help</yellow>")
print_out("")
print_out("<green>{}</green>".format(CLIENT_WEBSITE))
def get_argument_parser(name, command):
parser = ArgumentParser(
prog='toot %s' % name,
description=command.description,
epilog=CLIENT_WEBSITE)
combined_args = command.arguments + common_args
if command.require_auth:
combined_args += common_auth_args
defaults = get_setting(f"commands.{name}", dict, {})
for args, kwargs in combined_args:
# Set default value from settings if exists
default = get_default_value(defaults, args)
if default is not None:
kwargs["default"] = default
parser.add_argument(*args, **kwargs)
return parser
def get_default_value(defaults, args):
# Hacky way to determine command name from argparse args
name = args[-1].lstrip("-").replace("-", "_")
return defaults.get(name)
def run_command(app, user, name, args):
command = next((c for c in COMMANDS if c.name == name), None)
if not command:
print_err(f"Unknown command '{name}'")
print_out("Run <yellow>toot --help</yellow> to show a list of available commands.")
return
parser = get_argument_parser(name, command)
parsed_args = parser.parse_args(args)
# Override the active account if 'using' option is given
if command.require_auth and parsed_args.using:
user, app = config.get_user_app(parsed_args.using)
if not user or not app:
raise ConsoleError("User '{}' not found".format(parsed_args.using))
if command.require_auth and (not user or not app):
print_err("This command requires that you are logged in.")
print_err("Please run `toot login` first.")
return
fn = commands.__dict__.get(name)
if not fn:
raise NotImplementedError("Command '{}' does not have an implementation.".format(name))
return fn(app, user, parsed_args)
def main():
if settings.get_debug():
filename = settings.get_debug_file()
logging.basicConfig(level=logging.DEBUG, filename=filename)
logging.getLogger("urllib3").setLevel(logging.INFO)
command_name = sys.argv[1] if len(sys.argv) > 1 else None
args = sys.argv[2:]
if not command_name or command_name == "--help":
return print_usage()
user, app = config.get_active_user_app()
try:
run_command(app, user, command_name, args)
except (ConsoleError, ApiError) as e:
print_err(str(e))
sys.exit(1)
except KeyboardInterrupt:
pass

Wyświetl plik

@ -9,14 +9,22 @@ different versions of the Mastodon API.
"""
import dataclasses
import typing as t
from dataclasses import dataclass, is_dataclass
from datetime import date, datetime
from typing import Dict, List, Optional, Type, TypeVar, Union
from typing import get_type_hints
from functools import lru_cache
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
@ -57,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]
@ -71,9 +79,10 @@ class Account:
statuses_count: int
followers_count: int
following_count: int
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
@ -151,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
@ -204,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
@ -217,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]
@ -234,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
@ -256,12 +265,23 @@ 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: 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.
# Possible underlying issue:
# https://git.pleroma.social/pleroma/pleroma/-/issues/2851
if not obj["created_at"]:
obj["created_at"] = datetime.now().astimezone().isoformat()
return obj
@dataclass
class Report:
@ -275,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
@ -314,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
@ -363,20 +383,105 @@ 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]
# Generic data class instance
T = TypeVar("T")
@dataclass
class Relationship:
"""
Represents the relationship between accounts, such as following / blocking /
muting / etc.
https://docs.joinmastodon.org/entities/Relationship/
"""
id: str
following: bool
showing_reblogs: bool
notifying: bool
languages: t.List[str]
followed_by: bool
blocking: bool
blocked_by: bool
muting: bool
muting_notifications: bool
requested: bool
domain_blocking: bool
endorsed: bool
note: str
def from_dict(cls: Type[T], data: Dict) -> T:
@dataclass
class TagHistory:
"""
Usage statistics for given days (typically the past week).
https://docs.joinmastodon.org/entities/Tag/#history
"""
day: str
uses: str
accounts: str
@dataclass
class Tag:
"""
Represents a hashtag used within the content of a status.
https://docs.joinmastodon.org/entities/Tag/
"""
name: str
url: str
history: t.List[TagHistory]
following: Optional[bool]
@dataclass
class FeaturedTag:
"""
Represents a hashtag that is featured on a profile.
https://docs.joinmastodon.org/entities/FeaturedTag/
"""
id: str
name: str
url: str
statuses_count: int
last_status_at: datetime
@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: 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}"
)
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)
@ -384,17 +489,32 @@ def from_dict(cls: Type[T], data: Dict) -> T:
data = prepare(data)
def _fields():
hints = get_type_hints(cls)
for field in dataclasses.fields(cls):
field_type = _prune_optional(hints[field.name])
default_value = _get_default_value(field)
value = data.get(field.name, default_value)
yield field.name, _convert(field_type, value)
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()))
def _get_default_value(field):
@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)
)
for field in dataclasses.fields(cls)
]
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: dataclasses.Field):
if field.default is not dataclasses.MISSING:
return field.default
@ -404,7 +524,16 @@ def _get_default_value(field):
return None
def _convert(field_type, value):
def _convert_with_error_handling(data_class: type, field: Field, field_value: Any) -> Any:
try:
return _convert(field.type, field_value)
except ConversionError:
raise
except Exception:
raise ConversionError(data_class, field, field_value)
def _convert(field_type: Any, value: Any) -> Any:
if value is None:
return None
@ -412,7 +541,7 @@ def _convert(field_type, value):
return value
if field_type == datetime:
return datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%f%z")
return parse_datetime(value)
if field_type == date:
return date.fromisoformat(value)
@ -427,7 +556,7 @@ def _convert(field_type, value):
raise ValueError(f"Not implemented for type '{field_type}'")
def _prune_optional(field_type):
def _prune_optional(field_type: type) -> type:
"""For `Optional[<type>]` returns the encapsulated `<type>`."""
if get_origin(field_type) == Union:
args = get_args(field_type)

Wyświetl plik

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

Wyświetl plik

@ -3,7 +3,7 @@ from requests.exceptions import RequestException
from toot import __version__
from toot.exceptions import NotFoundError, ApiError
from toot.logging import log_request, log_response
from toot.logging import log_request, log_request_exception, log_response
def send_request(request, allow_redirects=True):
@ -19,6 +19,7 @@ def send_request(request, allow_redirects=True):
settings = session.merge_environment_settings(prepared.url, {}, None, None, None)
response = session.send(prepared, allow_redirects=allow_redirects, **settings)
except RequestException as ex:
log_request_exception(request, ex)
raise ApiError(f"Request failed: {str(ex)}")
log_response(response)
@ -37,7 +38,7 @@ def _get_error_message(response):
except Exception:
pass
return "Unknown error"
return f"Unknown error: {response.status_code} {response.reason}"
def process_response(response):
@ -80,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

@ -2,7 +2,7 @@ import json
import sys
from logging import getLogger
from requests import Request, Response
from requests import Request, RequestException, Response
from urllib.parse import urlencode
logger = getLogger("toot")
@ -56,6 +56,10 @@ def log_response(response: Response):
logger.debug(f" <-- {content}")
def log_request_exception(request: Request, ex: RequestException):
logger.debug(f" <-- {request.method} {_url(request)} Exception: {ex}")
def _url(request):
url = request.url
if request.params:

Wyświetl plik

@ -1,251 +1,145 @@
import os
import click
import re
import sys
import shutil
import textwrap
import typing as t
from functools import lru_cache
from toot import settings
from toot.entities import Instance, Notification, Poll, Status
from toot.utils import get_text, parse_html
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 List
from wcwidth import wcswidth
STYLES = {
'reset': '\033[0m',
'bold': '\033[1m',
'dim': '\033[2m',
'italic': '\033[3m',
'underline': '\033[4m',
'red': '\033[91m',
'green': '\033[92m',
'yellow': '\033[93m',
'blue': '\033[94m',
'magenta': '\033[95m',
'cyan': '\033[96m',
}
STYLE_TAG_PATTERN = re.compile(r"""
(?<!\\) # not preceeded by a backslash - allows escaping
< # literal
(/)? # optional closing - first group
(.*?) # style names - ungreedy - second group
> # literal
""", re.X)
DEFAULT_WIDTH = 80
def colorize(message):
"""
Replaces style tags in `message` with ANSI escape codes.
Markup is inspired by HTML, but you can use multiple words pre tag, e.g.:
<red bold>alert!</red bold> a thing happened
Empty closing tag will reset all styes:
<red bold>alert!</> a thing happened
Styles can be nested:
<red>red <underline>red and underline</underline> red</red>
"""
def _codes(styles):
for style in styles:
yield STYLES.get(style, "")
def _generator(message):
# A list is used instead of a set because we want to keep style order
# This allows nesting colors, e.g. "<blue>foo<red>bar</red>baz</blue>"
position = 0
active_styles = []
for match in re.finditer(STYLE_TAG_PATTERN, message):
is_closing = bool(match.group(1))
styles = match.group(2).strip().split()
start, end = match.span()
# Replace backslash for escaped <
yield message[position:start].replace("\\<", "<")
if is_closing:
yield STYLES["reset"]
# Empty closing tag resets all styles
if styles == []:
active_styles = []
else:
active_styles = [s for s in active_styles if s not in styles]
yield from _codes(active_styles)
else:
active_styles = active_styles + styles
yield from _codes(styles)
position = end
if position == 0:
# Nothing matched, yield the original string
yield message
else:
# Yield the remaining fragment
yield message[position:]
# Reset styles at the end to prevent leaking
yield STYLES["reset"]
return "".join(_generator(message))
def get_max_width() -> int:
return click.get_current_context().max_content_width or DEFAULT_WIDTH
def strip_tags(message):
return re.sub(STYLE_TAG_PATTERN, "", message)
def get_terminal_width() -> int:
return shutil.get_terminal_size().columns
@lru_cache(maxsize=None)
def use_ansi_color():
"""Returns True if ANSI color codes should be used."""
# Windows doesn't support color unless ansicon is installed
# See: http://adoxa.altervista.org/ansicon/
if sys.platform == 'win32' and 'ANSICON' not in os.environ:
return False
# Don't show color if stdout is not a tty, e.g. if output is piped on
if not sys.stdout.isatty():
return False
# Don't show color if explicitly specified in options
if "--no-color" in sys.argv:
return False
# Check in settings
color = settings.get_setting("common.color", bool)
if color is not None:
return color
# Use color by default
return True
def get_width() -> int:
return min(get_terminal_width(), get_max_width())
def print_out(*args, **kwargs):
if not settings.get_quiet():
args = [colorize(a) if use_ansi_color() else strip_tags(a) for a in args]
print(*args, **kwargs)
def print_err(*args, **kwargs):
args = [f"<red>{a}</red>" for a in args]
args = [colorize(a) if use_ansi_color() else strip_tags(a) for a in args]
print(*args, file=sys.stderr, **kwargs)
def print_warning(text: str):
click.secho(f"Warning: {text}", fg="yellow", err=True)
def print_instance(instance: Instance):
print_out(f"<green>{instance.title}</green>")
print_out(f"<blue>{instance.uri}</blue>")
print_out(f"running Mastodon {instance.version}")
print_out()
width = get_width()
click.echo(instance_to_text(instance, width))
def instance_to_text(instance: Instance, width: int) -> str:
return "\n".join(instance_lines(instance, width))
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}"
yield ""
if instance.description:
for paragraph in re.split(r"[\r\n]+", instance.description.strip()):
paragraph = get_text(paragraph)
print_out(textwrap.fill(paragraph, width=80))
print_out()
yield textwrap.fill(paragraph, width=width)
yield ""
if instance.rules:
print_out("Rules:")
yield "Rules:"
for ordinal, rule in enumerate(instance.rules):
ordinal = f"{ordinal + 1}."
lines = textwrap.wrap(rule.text, 80 - len(ordinal))
lines = textwrap.wrap(rule.text, width - len(ordinal))
first = True
for line in lines:
if first:
print_out(f"{ordinal} {line}")
yield f"{ordinal} {line}"
first = False
else:
print_out(f"{' ' * len(ordinal)} {line}")
print_out()
yield f"{' ' * len(ordinal)} {line}"
yield ""
contact = instance.contact_account
if contact:
print_out(f"Contact: {contact.display_name} @{contact.acct}")
yield f"Contact: {contact.display_name} @{contact.acct}"
def print_account(account):
print_out(f"<green>@{account['acct']}</green> {account['display_name']}")
if account["note"]:
print_out("")
print_html(account["note"])
print_out("")
print_out(f"ID: <green>{account['id']}</green>")
print_out(f"Since: <green>{account['created_at'][:10]}</green>")
print_out("")
print_out(f"Followers: <yellow>{account['followers_count']}</yellow>")
print_out(f"Following: <yellow>{account['following_count']}</yellow>")
print_out(f"Statuses: <yellow>{account['statuses_count']}</yellow>")
if account["fields"]:
for field in account["fields"]:
name = field["name"].title()
print_out(f'\n<yellow>{name}</yellow>:')
print_html(field["value"])
if field["verified_at"]:
print_out("<green>✓ Verified</green>")
print_out("")
print_out(account["url"])
def print_account(account: Account) -> None:
width = get_width()
click.echo(account_to_text(account, width))
HASHTAG_PATTERN = re.compile(r'(?<!\w)(#\w+)\b')
def account_to_text(account: Account, width: int) -> str:
return "\n".join(account_lines(account, width))
def highlight_hashtags(line):
return re.sub(HASHTAG_PATTERN, '<cyan>\\1</cyan>', line)
def account_lines(account: Account, width: int) -> t.Generator[str, None, None]:
acct = f"@{account.acct}"
since = account.created_at.strftime("%Y-%m-%d")
yield f"{green(acct)} {account.display_name}"
if account.note:
yield ""
yield from html_lines(account.note, width)
yield ""
yield f"ID: {green(account.id)}"
yield f"Since: {green(since)}"
yield ""
yield f"Followers: {yellow(account.followers_count)}"
yield f"Following: {yellow(account.following_count)}"
yield f"Statuses: {yellow(account.statuses_count)}"
if account.fields:
for field in account.fields:
name = field.name.title()
yield f'\n{yellow(name)}:'
yield from html_lines(field.value, width)
if field.verified_at:
yield green("✓ Verified")
yield ""
yield account.url
def print_acct_list(accounts):
for account in accounts:
print_out(f"* <green>@{account['acct']}</green> {account['display_name']}")
def print_user_list(users):
for user in users:
print_out(f"* {user}")
acct = green(f"@{account['acct']}")
click.echo(f"* {acct} {account['display_name']}")
def print_tag_list(tags):
if tags:
for tag in tags:
print_out(f"* <green>#{tag['name']}\t</green>{tag['url']}")
else:
print_out("You're not following any hashtags.")
for tag in 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)]
def style(string, tag):
return f"<{tag}>{string}</{tag}>" if tag else string
def print_row(row, tag=None):
def print_row(row):
for idx, cell in enumerate(row):
width = widths[idx]
print_out(style(cell.ljust(width), tag), end="")
print_out(" ", end="")
print_out()
click.echo(cell.ljust(width), nl=False)
click.echo(" ", nl=False)
click.echo()
underlines = ["-" * width for width in widths]
print_row(headers, "bold")
print_row(underlines, "dim")
print_row(headers)
print_row(underlines)
for row in data:
print_row(row)
@ -253,33 +147,42 @@ def print_table(headers: List[str], data: List[List[str]]):
def print_list_accounts(accounts):
if accounts:
print_out("Accounts in list</green>:\n")
click.echo("Accounts in list:\n")
print_acct_list(accounts)
else:
print_out("This list has no accounts.")
click.echo("This list has no accounts.")
def print_search_results(results):
accounts = results['accounts']
hashtags = results['hashtags']
accounts = results["accounts"]
hashtags = results["hashtags"]
if accounts:
print_out("\nAccounts:")
click.echo("\nAccounts:")
print_acct_list(accounts)
if hashtags:
print_out("\nHashtags:")
print_out(", ".join([f"<green>#{t['name']}</green>" for t in hashtags]))
click.echo("\nHashtags:")
click.echo(", ".join([format_tag_name(tag) for tag in hashtags]))
if not accounts and not hashtags:
print_out("<yellow>Nothing found</yellow>")
click.echo("Nothing found")
def print_status(status: Status, width: int = 80):
def print_status(status: Status) -> None:
width = get_width()
click.echo(status_to_text(status, width))
def status_to_text(status: Status, width: int) -> str:
return "\n".join(status_lines(status))
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
reblogged_by = status.account if status.reblog else None
status = status.original
time = status.created_at.strftime('%Y-%m-%d %H:%M %Z')
@ -287,61 +190,60 @@ def print_status(status: Status, width: int = 80):
spacing = width - wcswidth(username) - wcswidth(time) - 2
display_name = status.account.display_name
if display_name:
author = f"{green(display_name)} {blue(username)}"
spacing -= wcswidth(display_name) + 1
else:
author = blue(username)
print_out(
f"<green>{display_name}</green>" if display_name else "",
f"<blue>{username}</blue>",
" " * spacing,
f"<yellow>{time}</yellow>",
)
spaces = " " * spacing
yield f"{author} {spaces} {yellow(time)}"
print_out("")
print_html(status.content, width)
yield ""
yield from html_lines(status.content, width)
if status.media_attachments:
print_out("\nMedia:")
yield ""
yield "Media:"
for attachment in status.media_attachments:
url = attachment.url
for line in wc_wrap(url, width):
print_out(line)
yield line
if status.poll:
print_poll(status.poll)
yield from poll_lines(status.poll)
print_out()
reblogged_by_acct = f"@{reblogged_by.acct}" if reblogged_by else None
yield ""
print_out(
f"ID <yellow>{status_id}</yellow> ",
f"↲ In reply to <yellow>{in_reply_to_id}</yellow> " if in_reply_to_id else "",
f"↻ <blue>@{reblogged_by.acct}</blue> boosted " if reblogged_by else "",
)
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)} Visibility: {status.visibility} {reply} {boost}"
def print_html(text, width=80):
def html_lines(html: str, width: int) -> t.Generator[str, None, None]:
first = True
for paragraph in parse_html(text):
for paragraph in html_to_paragraphs(html):
if not first:
print_out("")
yield ""
for line in paragraph:
for subline in wc_wrap(line, width):
print_out(highlight_hashtags(subline))
yield subline
first = False
def print_poll(poll: Poll):
print_out()
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)
if poll.voted and poll.own_votes and idx in poll.own_votes:
voted_for = " <yellow></yellow>"
voted_for = yellow(" ")
else:
voted_for = ""
print_out(f'{option.title} - {perc}% {voted_for}')
yield f"{option.title} - {perc}% {voted_for}"
poll_footer = f'Poll · {poll.votes_count} votes'
@ -352,38 +254,87 @@ def print_poll(poll: Poll):
expires_at = poll.expires_at.strftime("%Y-%m-%d %H:%M")
poll_footer += f" · Closes on {expires_at}"
print_out()
print_out(poll_footer)
yield ""
yield poll_footer
def print_timeline(items: List[Status], width=100):
print_out("" * width)
def print_timeline(items: t.Iterable[Status]):
print_divider()
for item in items:
print_status(item, width)
print_out("" * width)
print_status(item)
print_divider()
notification_msgs = {
"follow": "{account} now follows you",
"mention": "{account} mentioned you in",
"reblog": "{account} reblogged your status",
"favourite": "{account} favourited your status",
}
def print_notification(notification: Notification, width=100):
account = f"{notification.account.display_name} @{notification.account.acct}"
msg = notification_msgs.get(notification.type)
if msg is None:
return
print_out("" * width)
print_out(msg.format(account=account))
def print_notification(notification: Notification):
print_notification_header(notification)
if notification.status:
print_status(notification.status, width)
print_divider(char="-")
print_status(notification.status)
def print_notifications(notifications: List[Notification], width=100):
def print_notifications(notifications: t.List[Notification]):
for notification in notifications:
print_notification(notification)
print_out("" * width)
if notification.type not in ['pleroma:emoji_reaction']:
print_divider()
print_notification(notification)
print_divider()
def print_notification_header(notification: Notification):
account_name = format_account_name(notification.account)
if (notification.type == "follow"):
click.echo(f"{account_name} now follows you")
elif (notification.type == "mention"):
click.echo(f"{account_name} mentioned you")
elif (notification.type == "reblog"):
click.echo(f"{account_name} reblogged your status")
elif (notification.type == "favourite"):
click.echo(f"{account_name} favourited your status")
elif (notification.type == "update"):
click.echo(f"{account_name} edited a post")
else:
click.secho(f"Unknown notification type: '{notification.type}'", err=True, fg="yellow")
click.secho("Please report an issue to toot.", err=True, fg="yellow")
def print_divider(char: str = ""):
click.echo(char * get_width())
def format_tag_name(tag):
return green(f"#{tag['name']}")
def format_account_name(account: Account) -> str:
acct = blue(f"@{account.acct}")
if account.display_name:
return f"{green(account.display_name)} {acct}"
else:
return acct
# Shorthand functions for coloring output
def blue(text: t.Any) -> str:
return click.style(text, fg="blue")
def bold(text: t.Any) -> str:
return click.style(text, bold=True)
def cyan(text: t.Any) -> str:
return click.style(text, fg="cyan")
def dim(text: t.Any) -> str:
return click.style(text, dim=True)
def green(text: t.Any) -> str:
return click.style(text, fg="green")
def yellow(text: t.Any) -> str:
return click.style(text, fg="yellow")

Wyświetl plik

@ -1,6 +1,3 @@
import os
import sys
from functools import lru_cache
from os.path import exists, join
from tomlkit import parse
@ -17,7 +14,7 @@ def get_settings_path():
return join(get_config_dir(), TOOT_SETTINGS_FILE_NAME)
def load_settings() -> dict:
def _load_settings() -> dict:
# Used for testing without config file
if DISABLE_SETTINGS:
return {}
@ -33,7 +30,7 @@ def load_settings() -> dict:
@lru_cache(maxsize=None)
def get_settings():
return load_settings()
return _load_settings()
T = TypeVar("T")
@ -62,26 +59,3 @@ def _get_setting(dct, keys, type: Type, default=None):
return _get_setting(dct[key], keys[1:], type, default)
return default
def get_debug() -> bool:
if "--debug" in sys.argv:
return True
return get_setting("common.debug", bool, False)
def get_debug_file() -> Optional[str]:
from_env = os.getenv("TOOT_LOG_FILE")
if from_env:
return from_env
return get_setting("common.debug_file", str)
@lru_cache(maxsize=None)
def get_quiet():
if "--quiet" in sys.argv:
return True
return get_setting("common.quiet", str, False)

Wyświetl plik

@ -1,20 +1,28 @@
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.console import get_default_visibility
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, show_media, 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__)
@ -24,6 +32,16 @@ urwid.set_encoding('UTF-8')
DEFAULT_MAX_TOOT_CHARS = 500
class TuiOptions(NamedTuple):
colors: int
media_viewer: Optional[str]
always_show_sensitive: bool
relative_datetimes: bool
cache_size: int
default_visibility: Optional[str]
image_format: Optional[str]
class Header(urwid.WidgetWrap):
def __init__(self, app, user):
self.app = app
@ -79,9 +97,11 @@ class TUI(urwid.Frame):
screen: urwid.BaseScreen
@staticmethod
def create(app, user, args):
def create(app: App, user: User, args: TuiOptions):
"""Factory method, sets up TUI and an event loop."""
screen = TUI.create_screen(args)
screen = TuiScreen()
screen.set_terminal_properties(args.colors)
tui = TUI(app, user, screen, args)
palette = PALETTE.copy()
@ -100,23 +120,11 @@ class TUI(urwid.Frame):
return tui
@staticmethod
def create_screen(args):
screen = urwid.raw_display.Screen()
# Determine how many colors to use
default_colors = 1 if args.no_color else 16
colors = settings.get_setting("tui.colors", int, default_colors)
logger.debug(f"Setting colors to {colors}")
screen.set_terminal_properties(colors)
return screen
def __init__(self, app, user, screen, args):
def __init__(self, app, user, screen, options: TuiOptions):
self.app = app
self.user = user
self.args = args
self.config = config.load_config()
self.options = options
self.loop = None # late init, set in `create`
self.screen = screen
@ -137,15 +145,22 @@ class TUI(urwid.Frame):
self.exception = None
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_followed_accounts())
self.loop.set_alarm_in(0, lambda *args: self.async_load_followed_tags())
self.loop.set_alarm_in(0, lambda *args: self.async_load_preferences())
self.loop.set_alarm_in(0, lambda *args: self.async_load_timeline(
is_initial=True, timeline_name="home"))
self.loop.set_alarm_in(0, lambda *args: self.async_load_followed_accounts())
self.loop.run()
self.executor.shutdown(wait=False)
@ -173,8 +188,8 @@ class TUI(urwid.Frame):
return urwid.Filler(intro)
def run_in_thread(self, fn, args=[], kwargs={}, done_callback=None, error_callback=None):
"""Runs `fn(*args, **kwargs)` asynchronously in a separate thread.
def run_in_thread(self, fn, done_callback=None, error_callback=None):
"""Runs `fn` asynchronously in a separate thread.
On completion calls `done_callback` if `fn` exited cleanly, or
`error_callback` if an exception was caught. Callback methods are
@ -198,7 +213,10 @@ class TUI(urwid.Frame):
logger.exception(exception)
self.loop.set_alarm_in(0, lambda *args: _error_callback(exception))
future = self.executor.submit(fn, *args, **kwargs)
# TODO: replace by `self.loop.event_loop.run_in_executor` at some point
# Added in https://github.com/urwid/urwid/issues/575
# Not yet released at the time of this comment
future = self.loop.event_loop._loop.run_in_executor(self.executor, fn)
future.add_done_callback(_done)
return future
@ -249,7 +267,7 @@ class TUI(urwid.Frame):
# This is pretty fast, so it's probably ok to block while context is
# loaded, can be made async later if needed
context = api.context(self.app, self.user, status.original.id)
context = api.context(self.app, self.user, status.original.id).json()
ancestors = [self.make_status(s) for s in context["ancestors"]]
descendants = [self.make_status(s) for s in context["descendants"]]
statuses = ancestors + [status] + descendants
@ -304,7 +322,7 @@ class TUI(urwid.Frame):
See: https://github.com/mastodon/mastodon/issues/19328
"""
def _load_instance():
return api.get_instance(self.app.base_url)
return api.get_instance(self.app.base_url).json()
def _done(instance):
self.max_toot_chars = get_max_toot_chars(instance, DEFAULT_MAX_TOOT_CHARS)
@ -318,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:
@ -339,22 +372,6 @@ class TUI(urwid.Frame):
self.run_in_thread(_load_accounts, done_callback=_done_accounts)
def async_load_followed_tags(self):
def _load_tag_list():
try:
return api.followed_tags(self.app, self.user)
except ApiError:
# not supported by all Mastodon servers so fail silently if necessary
return []
def _done_tag_list(tags):
if len(tags) > 0:
self.followed_tags = [t["name"] for t in tags]
else:
self.followed_tags = []
self.run_in_thread(_load_tag_list, done_callback=_done_tag_list)
def refresh_footer(self, timeline):
"""Show status details in footer."""
status, index, count = timeline.get_focused_status_with_counts()
@ -414,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 []
@ -514,8 +565,20 @@ class TUI(urwid.Frame):
def show_media(self, status):
urls = [m["url"] for m in status.original.data["media_attachments"]]
if urls:
show_media(urls)
if not urls:
return
media_viewer = self.options.media_viewer
if media_viewer:
try:
subprocess.run([media_viewer] + urls)
except FileNotFoundError:
self.footer.set_error_message(f"Media viewer not found: '{media_viewer}'")
except Exception as ex:
self.exception = ex
self.footer.set_error_message("Failed invoking media viewer. Press X to see exception.")
else:
self.footer.set_error_message("Media viewer not configured")
def show_context_menu(self, status):
# TODO: show context menu
@ -538,10 +601,15 @@ class TUI(urwid.Frame):
))
def post_status(self, content, warning, visibility, in_reply_to_id):
data = api.post_status(self.app, self.user, content,
data = api.post_status(
self.app,
self.user,
content,
spoiler_text=warning,
visibility=visibility,
in_reply_to_id=in_reply_to_id)
in_reply_to_id=in_reply_to_id
).json()
status = self.make_status(data)
# TODO: fetch new items from the timeline?
@ -549,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",
)
@ -662,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
@ -676,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.console import get_default_visibility
from .constants import VISIBILITY_OPTIONS
from .widgets import Button, EditBox
@ -11,21 +9,22 @@ logger = logging.getLogger(__name__)
class StatusComposer(urwid.Frame):
"""
UI for compose and posting a status message.
UI for composing or editing a status message.
To edit a status, provide the original status in 'edit', and optionally
provide the status source (from the /status/:id/source API endpoint) in
'source'; this should have at least a 'text' member, and optionally
'spoiler_text'. If source is not provided, the formatted HTML will be
presented to the user for editing.
"""
signals = ["close", "post"]
def __init__(self, max_chars, username, in_reply_to=None):
def __init__(self, max_chars, username, visibility, in_reply_to=None,
edit=None, source=None):
self.in_reply_to = in_reply_to
self.max_chars = max_chars
self.username = username
text = self.get_initial_text(in_reply_to)
self.content_edit = EditBox(
edit_text=text, edit_pos=len(text), multiline=True, allow_tab=True)
urwid.connect_signal(self.content_edit.edit, "change", self.text_changed)
self.char_count = urwid.Text(["0/{}".format(max_chars)])
self.edit = edit
self.cw_edit = None
self.cw_add_button = Button("Add content warning",
@ -33,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', ''),
@ -57,6 +57,29 @@ PALETTE = [
('dim', 'dark gray', ''),
('highlight', 'yellow', ''),
('success', 'dark green', ''),
# HTML tag styling
('a', ',italics', '', 'italics'),
# em tag is mapped to i
('i', ',italics', '', 'italics'),
# strong tag is mapped to b
('b', ',bold', '', 'bold'),
# special case for bold + italic nested tags
('bi', ',bold,italics', '', ',bold,italics'),
('u', ',underline', '', ',underline'),
('del', ',strikethrough', '', ',strikethrough'),
('code', 'light gray, standout', '', ',standout'),
('pre', 'light gray, standout', '', ',standout'),
('blockquote', 'light gray', '', ''),
('h1', ',bold', '', ',bold'),
('h2', ',bold', '', ',bold'),
('h3', ',bold', '', ',bold'),
('h4', ',bold', '', ',bold'),
('h5', ',bold', '', ',bold'),
('h6', ',bold', '', ',bold'),
('class_mention_hashtag', 'light cyan', '', ''),
('class_hashtag', 'light cyan', '', ''),
]
VISIBILITY_OPTIONS = [

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

@ -4,11 +4,13 @@ import urwid
import webbrowser
from toot import __version__
from toot.utils import format_content
from .utils import highlight_hashtags, highlight_keys
from .widgets import Button, EditBox, SelectableText
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
class StatusSource(urwid.Padding):
"""Shows status data, as returned by the server, as formatted JSON."""
@ -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,12 +303,14 @@ 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()
for line in format_content(account["note"]):
yield urwid.Text(highlight_hashtags(line, followed_tags=set()))
widgetlist = html_to_widgets(account["note"])
for line in widgetlist:
yield (line)
yield urwid.Divider()
yield urwid.Text(["ID: ", ("highlight", f"{account['id']}")])
@ -312,8 +342,11 @@ class Account(urwid.ListBox):
name = field["name"].title()
yield urwid.Divider()
yield urwid.Text([("bold", f"{name.rstrip(':')}"), ":"])
for line in format_content(field["value"]):
yield urwid.Text(highlight_hashtags(line, followed_tags=set()))
widgetlist = html_to_widgets(field["value"])
for line in widgetlist:
yield (line)
if field["verified_at"]:
yield urwid.Text(("success", "✓ Verified"))
@ -325,17 +358,17 @@ def take_action(button: Button, self: Account):
action = button.get_label()
if action == "Confirm Follow":
self.relationship = api.follow(self.app, self.user, self.account["id"])
self.relationship = api.follow(self.app, self.user, self.account["id"]).json()
elif action == "Confirm Unfollow":
self.relationship = api.unfollow(self.app, self.user, self.account["id"])
self.relationship = api.unfollow(self.app, self.user, self.account["id"]).json()
elif action == "Confirm Mute":
self.relationship = api.mute(self.app, self.user, self.account["id"])
self.relationship = api.mute(self.app, self.user, self.account["id"]).json()
elif action == "Confirm Unmute":
self.relationship = api.unmute(self.app, self.user, self.account["id"])
self.relationship = api.unmute(self.app, self.user, self.account["id"]).json()
elif action == "Confirm Block":
self.relationship = api.block(self.app, self.user, self.account["id"])
self.relationship = api.block(self.app, self.user, self.account["id"]).json()
elif action == "Confirm Unblock":
self.relationship = api.unblock(self.app, self.user, self.account["id"])
self.relationship = api.unblock(self.app, self.user, self.account["id"]).json()
self.last_action = None
self.setup_listbox()

Wyświetl plik

@ -2,11 +2,9 @@ import urwid
from toot import api
from toot.exceptions import ApiError
from toot.utils import format_content
from toot.utils.datetime import parse_datetime
from .utils import highlight_hashtags
from .widgets import Button, CheckBox, RadioButton
from .widgets import Button, CheckBox, RadioButton, RoundedLineBox
from .richtext import html_to_widgets
class Poll(urwid.ListBox):
@ -29,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")
@ -87,8 +85,11 @@ class Poll(urwid.ListBox):
def generate_contents(self, status):
yield urwid.Divider()
for line in format_content(status.data["content"]):
yield urwid.Text(highlight_hashtags(line, set()))
widgetlist = html_to_widgets(status.data["content"])
for line in widgetlist:
yield (line)
yield urwid.Divider()
yield self.build_linebox(self.generate_poll_detail())

Wyświetl plik

@ -0,0 +1,18 @@
import urwid
from toot.tui.utils import highlight_hashtags
from toot.utils import format_content
from typing import List
try:
from .richtext import html_to_widgets, url_to_widget
except ImportError:
# Fallback if urwidgets are not available
def html_to_widgets(html: str) -> List[urwid.Widget]:
return [
urwid.Text(highlight_hashtags(line))
for line in format_content(html)
]
def url_to_widget(url: str):
return urwid.Text(("link", url))

Wyświetl plik

@ -0,0 +1,461 @@
import re
import urwid
import unicodedata
from bs4.element import NavigableString, Tag
from toot.tui.constants import PALETTE
from toot.utils import parse_html, urlencode_url
from typing import List, Tuple
from urwid.util import decompose_tagmarkup
from urwidgets import Hyperlink, TextEmbed
STYLE_NAMES = [p[0] for p in PALETTE]
# NOTE: update this list if Mastodon starts supporting more block tags
BLOCK_TAGS = ["p", "pre", "li", "blockquote", "h1", "h2", "h3", "h4", "h5", "h6"]
def html_to_widgets(html, recovery_attempt=False) -> List[urwid.Widget]:
"""Convert html to urwid widgets"""
widgets: List[urwid.Widget] = []
html = unicodedata.normalize("NFKC", html)
soup = parse_html(html)
first_tag = True
for e in soup.body or soup:
if isinstance(e, NavigableString):
if first_tag and not recovery_attempt:
# if our first "tag" is a navigable string
# the HTML is out of spec, doesn't start with a tag,
# we see this in content from Pixelfed servers.
# attempt a fix by wrapping the HTML with <p></p>
return html_to_widgets(f"<p>{html}</p>", recovery_attempt=True)
else:
continue
else:
name = e.name
# if our HTML starts with a tag, but not a block tag
# the HTML is out of spec. Attempt a fix by wrapping the
# HTML with <p></p>
if (first_tag and not recovery_attempt and name not in BLOCK_TAGS):
return html_to_widgets(f"<p>{html}</p>", recovery_attempt=True)
markup = render(name, e)
first_tag = False
if not isinstance(markup, urwid.Widget):
# plaintext, so create a padded text widget
txt = text_to_widget("", markup)
markup = urwid.Padding(
txt,
align="left",
width=("relative", 100),
min_width=None,
)
widgets.append(markup)
# separate top level widgets with a blank line
widgets.append(urwid.Divider(" "))
return widgets[:-1] # but suppress the last blank line
def url_to_widget(url: str):
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)
def inline_tag_to_text(tag) -> Tuple:
"""Convert html tag to plain text with tag as attributes recursively"""
markups = process_inline_tag_children(tag)
if not markups:
return (tag.name, "")
return (tag.name, markups)
def process_inline_tag_children(tag) -> List:
"""Recursively retrieve all children
and convert to a list of markup text"""
markups = []
for child in tag.children:
if isinstance(child, Tag):
markup = render(child.name, child)
markups.append(markup)
else:
markups.append(child)
return markups
URL_PATTERN = re.compile(r"(^.+)\x03(.+$)")
def text_to_widget(attr, markup) -> urwid.Widget:
markup_list = []
for run in markup:
if isinstance(run, tuple):
txt, attr_list = decompose_tagmarkup(run)
# find anchor titles with an ETX separator followed by href
match = URL_PATTERN.match(txt)
if match:
label, url = match.groups()
anchor_attr = get_best_anchor_attr(attr_list)
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:
markup_list.append(run)
return TextEmbed(markup_list)
def process_block_tag_children(tag) -> List[urwid.Widget]:
"""Recursively retrieve all children
and convert to a list of widgets
any inline tags containing text will be
converted to Text widgets"""
pre_widget_markups = []
post_widget_markups = []
child_widgets = []
found_nested_widget = False
for child in tag.children:
if isinstance(child, Tag):
# child is a nested tag; process using custom method
# or default to inline_tag_to_text
result = render(child.name, child)
if isinstance(result, urwid.Widget):
found_nested_widget = True
child_widgets.append(result)
else:
if not found_nested_widget:
pre_widget_markups.append(result)
else:
post_widget_markups.append(result)
else:
# child is text; append to the appropriate markup list
if not found_nested_widget:
pre_widget_markups.append(child)
else:
post_widget_markups.append(child)
widget_list = []
if len(pre_widget_markups):
widget_list.append(text_to_widget(tag.name, pre_widget_markups))
if len(child_widgets):
widget_list += child_widgets
if len(post_widget_markups):
widget_list.append(text_to_widget(tag.name, post_widget_markups))
return widget_list
def get_urwid_attr_name(tag) -> str:
"""Get the class name and translate to a
name suitable for use as an urwid
text attribute name"""
if "class" in tag.attrs:
clss = tag.attrs["class"]
if len(clss) > 0:
style_name = "class_" + "_".join(clss)
# return the class name, only if we
# find it as a defined palette name
if style_name in STYLE_NAMES:
return style_name
# fallback to returning the tag name
return tag.name
def basic_block_tag_handler(tag) -> urwid.Widget:
"""default for block tags that need no special treatment"""
return urwid.Pile(process_block_tag_children(tag))
def get_best_anchor_attr(attrib_list) -> str:
if not attrib_list:
return ""
flat_al = list(flatten(attrib_list))
for a in flat_al[0]:
# ref: https://docs.joinmastodon.org/spec/activitypub/
# these are the class names (translated to attrib names)
# that we can support for display
try:
if a[0] in ["class_hashtag", "class_mention_hashtag", "class_mention"]:
return a[0]
except KeyError:
continue
return "a"
def render(attr: str, content: str):
if attr in ["a"]:
return render_anchor(content)
if attr in ["blockquote"]:
return render_blockquote(content)
if attr in ["br"]:
return render_br(content)
if attr in ["em"]:
return render_em(content)
if attr in ["ol"]:
return render_ol(content)
if attr in ["pre"]:
return render_pre(content)
if attr in ["span"]:
return render_span(content)
if attr in ["b", "strong"]:
return render_strong(content)
if attr in ["ul"]:
return render_ul(content)
# Glitch-soc and Pleroma allow <H1>...<H6> in content
# Mastodon (PR #23913) does not; header tags are converted to <P><STRONG></STRONG></P>
if attr in ["p", "div", "li", "h1", "h2", "h3", "h4", "h5", "h6"]:
return basic_block_tag_handler(content)
# Fall back to inline_tag_to_text handler
return inline_tag_to_text(content)
def render_anchor(tag) -> Tuple:
"""anchor tag handler"""
markups = process_inline_tag_children(tag)
if not markups:
return (tag.name, "")
href = tag.attrs["href"]
title, attrib_list = decompose_tagmarkup(markups)
if not attrib_list:
attrib_list = [tag]
if href:
# urlencode the path and query portions of the URL
href = urlencode_url(href)
# use ASCII ETX (end of record) as a
# delimiter between the title and the HREF
title += f"\x03{href}"
attr = get_best_anchor_attr(attrib_list)
if attr == "a":
# didn't find an attribute to use
# in the child markup, so let's
# try the anchor tag's own attributes
attr = get_urwid_attr_name(tag)
# hashtag anchors have a class of "mention hashtag"
# or "hashtag"
# we'll return style "class_mention_hashtag"
# or "class_hashtag"
# in that case; see corresponding palette entry
# in constants.py controlling hashtag highlighting
return (attr, title)
def render_blockquote(tag) -> urwid.Widget:
widget_list = process_block_tag_children(tag)
blockquote_widget = urwid.LineBox(
urwid.Padding(
urwid.Pile(widget_list),
align="left",
width=("relative", 100),
min_width=None,
left=1,
right=1,
),
tlcorner="",
tline="",
lline="",
trcorner="",
blcorner="",
rline="",
bline="",
brcorner="",
)
return urwid.Pile([urwid.AttrMap(blockquote_widget, "blockquote")])
def render_br(tag) -> Tuple:
return ("br", "\n")
def render_em(tag) -> Tuple:
# to simplify the number of palette entries
# translate EM to I (italic)
markups = process_inline_tag_children(tag)
if not markups:
return ("i", "")
# special case processing for bold and italic
for parent in tag.parents:
if parent.name == "b" or parent.name == "strong":
return ("bi", markups)
return ("i", markups)
def render_ol(tag) -> urwid.Widget:
"""ordered list tag handler"""
widgets = []
list_item_num = 1
increment = -1 if tag.has_attr("reversed") else 1
# get ol start= attribute if present
if tag.has_attr("start") and len(tag.attrs["start"]) > 0:
try:
list_item_num = int(tag.attrs["start"])
except ValueError:
pass
for li in tag.find_all("li", recursive=False):
markup = render("li", li)
# li value= attribute will change the item number
# it also overrides any ol start= attribute
if li.has_attr("value") and len(li.attrs["value"]) > 0:
try:
list_item_num = int(li.attrs["value"])
except ValueError:
pass
if not isinstance(markup, urwid.Widget):
txt = text_to_widget("li", [str(list_item_num), ". ", markup])
# 1. foo, 2. bar, etc.
widgets.append(txt)
else:
txt = text_to_widget("li", [str(list_item_num), ". "])
columns = urwid.Columns(
[txt, ("weight", 9999, markup)], dividechars=1, min_width=3
)
widgets.append(columns)
list_item_num += increment
return urwid.Pile(widgets)
def render_pre(tag) -> urwid.Widget:
# <PRE> tag spec says that text should not wrap,
# but horizontal screen space is at a premium
# and we have no horizontal scroll bar, so allow
# wrapping.
widget_list = [urwid.Divider(" ")]
widget_list += process_block_tag_children(tag)
pre_widget = urwid.Padding(
urwid.Pile(widget_list),
align="left",
width=("relative", 100),
min_width=None,
left=1,
right=1,
)
return urwid.Pile([urwid.AttrMap(pre_widget, "pre")])
def render_span(tag) -> Tuple:
markups = process_inline_tag_children(tag)
if not markups:
return (tag.name, "")
# span inherits its parent's class definition
# unless it has a specific class definition
# of its own
if "class" in tag.attrs:
# uncomment the following code to hide all HTML marked
# invisible (generally, the http:// prefix of URLs)
# could be a user preference, it's only advisable if
# the terminal supports OCS 8 hyperlinks (and that's not
# automatically detectable)
# if "invisible" in tag.attrs["class"]:
# return (tag.name, "")
style_name = get_urwid_attr_name(tag)
if style_name != "span":
# unique class name matches an entry in our palette
return (style_name, markups)
if tag.parent:
return (get_urwid_attr_name(tag.parent), markups)
else:
# fallback
return ("span", markups)
def render_strong(tag) -> Tuple:
# to simplify the number of palette entries
# translate STRONG to B (bold)
markups = process_inline_tag_children(tag)
if not markups:
return ("b", "")
# special case processing for bold and italic
for parent in tag.parents:
if parent.name == "i" or parent.name == "em":
return ("bi", markups)
return ("b", markups)
def render_ul(tag) -> urwid.Widget:
"""unordered list tag handler"""
widgets = []
for li in tag.find_all("li", recursive=False):
markup = render("li", li)
if not isinstance(markup, urwid.Widget):
txt = text_to_widget("li", ["\N{bullet} ", markup])
# * foo, * bar, etc.
widgets.append(txt)
else:
txt = text_to_widget("li", ["\N{bullet} "])
columns = urwid.Columns(
[txt, ("weight", 9999, markup)], dividechars=1, min_width=3
)
widgets.append(columns)
return urwid.Pile(widgets)
def flatten(data):
if isinstance(data, tuple):
for x in data:
yield from flatten(x)
else:
yield data

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.utils import format_content
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 .entities import Status
from .scroll import Scrollable, ScrollBar
from .utils import highlight_hashtags, highlight_keys
from .widgets import SelectableText, SelectableColumns
from toot.entities import Status
from toot.tui.scroll import Scrollable, ScrollBar
from toot.tui.utils import highlight_keys
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]
@ -79,13 +87,13 @@ class Timeline(urwid.Columns):
return urwid.ListBox(walker)
def build_list_item(self, status):
item = StatusListItem(status, self.tui.args.relative_datetimes)
item = StatusListItem(status, self.tui.options.relative_datetimes)
urwid.connect_signal(item, "click", lambda *args:
self.tui.show_context_menu(status))
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",
})
@ -95,16 +103,19 @@ class Timeline(urwid.Columns):
return None
poll = status.original.data.get("poll")
show_media = status.original.data["media_attachments"] and self.tui.options.media_viewer
options = [
"[A]ccount" if not status.is_mine else "",
"[B]oost",
"[D]elete" if status.is_mine else "",
"[E]dit" if status.is_mine else "",
"B[o]okmark",
"[F]avourite",
"[V]iew",
"[T]hread" if not self.is_thread else "",
"L[i]nks",
"[M]edia" if show_media else "",
"[R]eply",
"[P]oll" if poll and not poll["expired"] else "",
"So[u]rce",
@ -138,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")
@ -187,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
@ -274,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))
@ -298,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
@ -310,25 +357,94 @@ class Timeline(urwid.Columns):
class StatusDetails(urwid.Pile):
def __init__(self, timeline: Timeline, status: Optional[Status]):
self.status = status
self.followed_tags = timeline.tui.followed_tags
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"]:
@ -336,12 +452,17 @@ 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"]
for line in format_content(content):
yield ("pack", urwid.Text(highlight_hashtags(line, self.followed_tags)))
widgetlist = html_to_widgets(content)
for line in widgetlist:
yield (line)
media = status.data["media_attachments"]
if media:
@ -350,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", urwid.Text(("link", 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:
@ -385,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']} "),
@ -400,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()))
@ -410,7 +553,16 @@ class StatusDetails(urwid.Pile):
if card["description"]:
yield urwid.Text(card["description"].strip())
yield urwid.Text("")
yield urwid.Text(("link", card["url"]))
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"]):
@ -439,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
@ -453,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,9 +1,8 @@
import base64
import re
import shutil
import subprocess
import sys
import urwid
from collections import OrderedDict
from functools import reduce
from html.parser import HTMLParser
from typing import List
@ -35,48 +34,19 @@ def highlight_keys(text, high_attr, low_attr=""):
return list(_gen())
def highlight_hashtags(line, followed_tags, attr="hashtag", followed_attr="hashtag_followed"):
def highlight_hashtags(line):
hline = []
for p in re.split(HASHTAG_PATTERN, line):
if p.startswith("#"):
if p[1:].lower() in (t.lower() for t in followed_tags):
hline.append((followed_attr, p))
else:
hline.append((attr, p))
hline.append(("hashtag", p))
else:
hline.append(p)
return hline
def show_media(paths):
"""
Attempt to open an image viewer to show given media files.
FIXME: This is not very thought out, but works for me.
Once settings are implemented, add an option for the user to configure their
prefered media viewer.
"""
viewer = None
potential_viewers = [
"feh",
"eog",
"display"
]
for v in potential_viewers:
viewer = shutil.which(v)
if viewer:
break
if not viewer:
raise Exception("Cannot find an image viewer")
subprocess.run([viewer] + paths)
class LinkParser(HTMLParser):
def reset(self):
super().reset()
self.links = []
@ -140,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,41 +1,42 @@
import click
import os
import re
import socket
import subprocess
import tempfile
import unicodedata
import warnings
from bs4 import BeautifulSoup
from typing import Dict
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)
def get_text(html):
"""Converts html to text, strips all tags."""
def parse_html(html: str) -> BeautifulSoup:
# Ignore warnings made by BeautifulSoup, if passed something that looks like
# a file (e.g. a dot which matches current dict), it will warn that the file
# should be opened instead of passing a filename.
with warnings.catch_warnings():
warnings.simplefilter("ignore")
text = BeautifulSoup(html.replace('&apos;', "'"), "html.parser").get_text()
return unicodedata.normalize('NFKC', text)
return BeautifulSoup(html.replace("&apos;", "'"), "html.parser")
def parse_html(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)
def html_to_paragraphs(html: str) -> List[List[str]]:
"""Attempt to convert html to plain text while keeping line breaks.
Returns a list of paragraphs, each being a list of lines.
"""
@ -48,13 +49,13 @@ def parse_html(html):
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.
"""
paragraphs = parse_html(content)
paragraphs = html_to_paragraphs(content)
first = True
@ -68,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())
@ -106,7 +94,7 @@ Everything below it will be ignored.
"""
def editor_input(editor: str, initial_text: str):
def editor_input(editor: str, initial_text: str) -> str:
"""Lets user input text using an editor."""
tmp_path = _tmp_status_path()
initial_text = (initial_text or "") + EDITOR_INPUT_INSTRUCTIONS
@ -122,18 +110,7 @@ def editor_input(editor: str, initial_text: str):
return f.read().split(EDITOR_DIVIDER)[0].strip()
def read_char(values, default):
values = [v.lower() for v in values]
while True:
value = input().lower()
if value == "":
return default
if value in values:
return value
def delete_tmp_status_file():
def delete_tmp_status_file() -> None:
try:
os.unlink(_tmp_status_path())
except FileNotFoundError:
@ -145,44 +122,28 @@ def _tmp_status_path() -> str:
return f"{tmp_dir}/.status.toot"
def _use_existing_tmp_file(tmp_path) -> bool:
from toot.output import print_out
def _use_existing_tmp_file(tmp_path: str) -> bool:
if os.path.exists(tmp_path):
print_out(f"<cyan>Found a draft status at: {tmp_path}</cyan>")
print_out("<cyan>[O]pen (default) or [D]elete?</cyan> ", end="")
char = read_char(["o", "d"], "o")
return char == "o"
click.echo(f"Found draft status at: {tmp_path}")
choice = click.Choice(["O", "D"], case_sensitive=False)
char = click.prompt("Open or Delete?", type=choice, default="O")
return char == "O"
return False
def drop_empty_values(data: Dict) -> Dict:
def drop_empty_values(data: Dict[Any, Any]) -> Dict[Any, Any]:
"""Remove keys whose values are null"""
return {k: v for k, v in data.items() if v is not None}
def args_get_instance(instance, scheme, default=None):
if not instance:
return default
def urlencode_url(url: str) -> str:
parsed_url = urlparse(url)
if scheme == "http":
_warn_scheme_deprecated()
# unencode before encoding, to prevent double-urlencoding
encoded_path = quote(unquote(parsed_url.path), safe="-._~()'!*:@,;+&=/")
encoded_query = urlencode({k: quote(unquote(v), safe="-._~()'!*:@,;?/") for k, v in parsed_url.params})
encoded_url = parsed_url._replace(path=encoded_path, params=encoded_query).geturl()
if instance.startswith("http"):
return instance.rstrip("/")
else:
return f"{scheme}://{instance}"
def _warn_scheme_deprecated():
from toot.output import print_err
print_err("\n".join([
"--disable-https flag is deprecated and will be removed.",
"Please specify the instance as URL instead.",
"e.g. instead of writing:",
" toot instance unsafehost.com --disable-https",
"instead write:",
" toot instance http://unsafehost.com\n"
]))
return encoded_url

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)

Wyświetl plik

@ -3,11 +3,12 @@ Utilities for dealing with string containing wide characters.
"""
import re
from typing import Generator, List
from wcwidth import wcwidth, wcswidth
def _wc_hard_wrap(line, length):
def _wc_hard_wrap(line: str, length: int) -> Generator[str, None, None]:
"""
Wrap text to length characters, breaking when target length is reached,
taking into account character width.
@ -20,7 +21,7 @@ def _wc_hard_wrap(line, length):
char_len = wcwidth(char)
if chars_len + char_len > length:
yield "".join(chars)
chars = []
chars: List[str] = []
chars_len = 0
chars.append(char)
@ -30,7 +31,7 @@ def _wc_hard_wrap(line, length):
yield "".join(chars)
def wc_wrap(text, length):
def wc_wrap(text: str, length: int) -> Generator[str, None, None]:
"""
Wrap text to given length, breaking on whitespace and taking into account
character width.
@ -38,7 +39,7 @@ def wc_wrap(text, length):
Meant for use on a single line or paragraph. Will destroy spacing between
words and paragraphs and any indentation.
"""
line_words = []
line_words: List[str] = []
line_len = 0
words = re.split(r"\s+", text.strip())
@ -66,7 +67,7 @@ def wc_wrap(text, length):
yield from _wc_hard_wrap(line, length)
def trunc(text, length):
def trunc(text: str, length: int) -> str:
"""
Truncates text to given length, taking into account wide characters.
@ -98,7 +99,7 @@ def trunc(text, length):
return text[:-n].strip() + ''
def pad(text, length):
def pad(text: str, length: int) -> str:
"""Pads text to given length, taking into account wide characters."""
text_length = wcswidth(text)
@ -108,7 +109,7 @@ def pad(text, length):
return text
def fit_text(text, length):
def fit_text(text: str, length: int) -> str:
"""Makes text fit the given length by padding or truncating it."""
text_length = wcswidth(text)