Porównaj commity

...

381 Commity

Autor SHA1 Wiadomość Data
Daniel Schwarz 31bbb20324 Make this fix compatible with latest master 2024-03-05 20:08:54 -05:00
Daniel Schwarz 9d59df6c7e Merge branch 'master' into asyncfix 2024-03-05 20:03:05 -05:00
Daniel Schwarz d21b2920cb Fix for compatibility with more recent versions of toot 2024-03-05 19:58:54 -05:00
Daniel Schwarz a5cd9d343c Merge branch 'asyncfix' of https://github.com/danschwarz/toot into asyncfix 2024-03-05 19:58:18 -05: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 d4b6447d0f
Bump version, add changelog 2023-11-16 16:41:15 +01:00
Ivan Habunek 6cb170b95c
Merge pull request #413 from ihabunek/preprocessing
Add data preprocessing
2023-11-16 16:38:15 +01:00
Ivan Habunek 2b8727bf09
Roll back workaround to support pleroma
Fixed in previous commit.
2023-11-16 15:15:38 +01:00
Ivan Habunek fe48f9a17e
Add a way to preprocess the data before decoding
Use it to modify the data returned by the Pleroma API which does not
conform to the current Mastodon API definition.

See:
https://git.pleroma.social/pleroma/pleroma/-/issues/1470#anchor-310
2023-11-16 15:15:26 +01: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
Sandra Snan 560b91700f
Ask for 639-1 consistently 2023-11-16 10:11:35 +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
Daniel Schwarz 30857f570d
Merge pull request #405 from snan/date-parsing 2023-09-24 09:18:27 -04:00
Sandra Snan b344bb3bdb Add tests for reading dates 2023-09-24 08:09:06 +02:00
Sandra Snan 6a22e2345b Add in controversial timezone workaround 2023-09-24 00:42:46 +02:00
Sandra Snan c821ab999b Fix crash on weird date time
Solution found by danschwarz
2023-09-24 00:35:39 +02: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
Ivan Habunek f6e454956b
Fix formatting 2023-07-25 09:40:11 +02:00
Ivan Habunek 2480135eda
Fix section name in docs 2023-07-25 09:38:22 +02:00
Ivan Habunek 8ff46fd0d0
Remove unused import 2023-07-25 09:36:53 +02:00
Ivan Habunek 0a70af9e09
Bump version, add changelog 2023-07-25 09:34:21 +02:00
Ivan Habunek 8c7bb25184
Fix relative datetimes in TUI 2023-07-25 09:32:17 +02:00
Ivan Habunek 5eeb9fdf49
Add changelog, bump version 2023-07-25 09:15:54 +02:00
Ivan Habunek b0099642cd
Shorten command descriptions 2023-07-25 09:14:58 +02:00
Ivan Habunek 5e8a7bb415
Merge pull request #385 from ihabunek/colors-settings
Read TUI colors from settings
2023-07-25 09:10:01 +02:00
Ivan Habunek 09aa4e8a1a
Merge pull request #390 from fobser/muted_blocked_commands
Add "muted" & "blocked" commands
2023-07-24 17:23:40 +02:00
Florian Obser acc80f6890 Implemented "blocked" command
This lists accounts the logged in user has blocked. It is useful for
creating regular backups.
2023-07-21 16:24:57 +02:00
Florian Obser 39d7a345ed Implemented "muted" command
This lists accounts the logged in user has muted. It is useful for
creating regular backups.
2023-07-21 16:24:38 +02:00
Ivan Habunek afd349f1ab
Remove the monochrome palette
Monochrome colors should be defined as the third color in each tuple
instead.

In monochrome mode it's not possible to set the background so all
highlights can only be done using bold, italics, underline...
2023-07-08 12:33:34 +02:00
Ivan Habunek 0c0f889e3f
Add docs for overriding palette 2023-07-08 12:20:10 +02:00
Ivan Habunek 0903dae8d3
Read TUI colors from settings 2023-07-08 12:08:06 +02:00
Ivan Habunek 740f7fafd4
Read TUI palette overrides from settings 2023-07-08 12:08:06 +02:00
Ivan Habunek 94b4b35197
Tweak colors 2023-07-08 12:08:06 +02:00
Ivan Habunek 4f62f417f8
Merge pull request #384 from danschwarz/constants
Added sanity check tests to ensure PALETTE and MONO_PALETTE match
2023-07-08 08:45:49 +02:00
Daniel Schwarz 28a3343761 Added sanity check tests to ensure PALETTE and MONO_PALETTE match
And fixed a bug uncovered by the above tests :)
2023-07-07 18:12:38 -04:00
Ivan Habunek 219225ba8a
Remove named colours
This is a prerequisite for configurable styling, since we don't want to
override "green" style to be yellow and such.
2023-07-07 13:57:19 +02:00
Ivan Habunek 9933180146
Improve colour names 2023-07-07 13:02:18 +02:00
Ivan Habunek 089e9f7d2f
Simplify delete status modal 2023-07-07 12:37:08 +02:00
Ivan Habunek d10c5fed3e
Add type hints for get_setting 2023-07-07 12:17:36 +02:00
Ivan Habunek f1e4c8d68f
Clean up how loop and screen are handled in tui 2023-07-07 11:49:26 +02:00
Ivan Habunek 207b6dadb2
Merge pull request #383 from danschwarz/lkey
Changed [L]inks key to L[i]nks so that L key is CURSOR RIGHT
2023-07-06 15:26:55 +02:00
Daniel Schwarz f292468fb6 Changed [L]inks key to L[i]nks so that L key is CURSOR RIGHT 2023-07-06 07:31:43 -04:00
Daniel Schwarz 3903307e72
Changed Help key to ?, Documented Save key correctly as E
Fixes #280, #370
2023-07-05 10:09:00 +02:00
Ivan Habunek d71cc7e3b6
Merge pull request #377 from ihabunek/settings
Implement a settings file
2023-06-30 14:59:14 +02:00
Ivan Habunek da9931e5e5
Remove datetime hack used for python < 3.7 2023-06-30 11:08:22 +02:00
Ivan Habunek 3df8e2722a
Extract datetime utils 2023-06-30 11:08:19 +02:00
Ivan Habunek 83b7f25678
Add __main__.py
This allows launching toot by:

  python -m toot
2023-06-30 11:02:13 +02:00
Ivan Habunek 7da372e4a8
Disable settings for testing 2023-06-28 14:55:28 +02:00
Ivan Habunek 38487a0774
Fix error in docs 2023-06-28 14:46:36 +02:00
Ivan Habunek d4f8acb3ce
Resolve circular import by moving get_config_dir 2023-06-28 14:46:35 +02:00
Ivan Habunek 953cad5023
Respect quiet setting 2023-06-28 14:17:35 +02:00
Ivan Habunek cee2c93815
Respect debug and debug_file settings 2023-06-28 14:17:20 +02:00
Ivan Habunek ee20b7ac0e
Add settings documentation 2023-06-28 13:57:29 +02:00
Ivan Habunek 4388175cb4
Respect color setting 2023-06-28 13:56:59 +02:00
Ivan Habunek 85260ed99d
Apply command defaults from settings 2023-06-28 13:55:30 +02:00
Ivan Habunek ed0baf0770
Implement reading from a settings file 2023-06-28 13:55:29 +02:00
Ivan Habunek de087b414c
Update changelog 2023-06-28 12:46:45 +02:00
Ivan Habunek bb625da257
Bump version, add changelog 2023-06-28 12:41:47 +02:00
Ivan Habunek 8aad38671e
Add integration tests for status and thread 2023-06-28 12:38:29 +02:00
Ivan Habunek 3a375b77ee
Add toot status command 2023-06-28 12:23:33 +02:00
Ivan Habunek 835f789145
Merge pull request #374 from ihabunek/entities2
Implement data classes for API entities
2023-06-27 07:54:30 +02:00
Ivan Habunek 8ad2b279ed
Ignore warning not to compare types
No other way around it here.
2023-06-26 17:02:58 +02:00
Ivan Habunek b2c2f7466e
Fix type hints for older versions of python 2023-06-26 17:00:39 +02:00
Ivan Habunek 3cd13f6885
Fix vermin checks 2023-06-26 17:00:38 +02:00
Ivan Habunek f3439ad30d
Use entitites to simpliy print functions 2023-06-26 16:59:14 +02:00
Ivan Habunek a0b9b37a4f
Add Instance and related entities 2023-06-26 16:59:14 +02:00
Ivan Habunek 368738831c
Add Notification and Report entities 2023-06-26 16:59:14 +02:00
Ivan Habunek 119c514ee9
Add entities 2023-06-26 16:59:14 +02:00
Ivan Habunek 5607bd75b3
Add compat code for typing functions 2023-06-26 16:59:13 +02:00
Ivan Habunek 0c89445b61
Fix type hint for older py versions 2023-06-26 16:58:11 +02:00
Ivan Habunek 0662a7616b
Read toot lenght limit from mastodon instances
fixes #369
2023-06-24 10:18:10 +02:00
Ivan Habunek 12d84ea05e
Fix return type 2023-06-24 10:04:40 +02:00
Ivan Habunek dff36e28f3
Remove redundant logging
These actions are already visible via http requests being logged.
2023-06-24 09:58:31 +02:00
Ivan Habunek a3435b4b96
Merge pull request #373 from danschwarz/nocolor
--no-color now works for tui mode
2023-06-24 09:54:18 +02:00
Daniel Schwarz 8c3cec1aef --no-color now works for tui mode 2023-06-23 18:16:14 -04:00
Ivan Habunek 0973bfd484
Refresh timeline when showing thread 2023-06-22 12:15:05 +02:00
Ivan Habunek 6e4baaaf9f
Ignore venv 2023-06-22 12:15:05 +02:00
Ivan Habunek 00223b3ba4
Bump min python version to 3.7 2023-06-22 12:15:05 +02:00
Ivan Habunek 653a18facc
Merge pull reuqest #360 2023-06-22 11:33:20 +02:00
Ivan Habunek 7b08e39f00
Decrease the amount of color 2023-06-22 11:33:07 +02:00
Daniel Schwarz 01afbe1f2f
If toot activate is invoked without an account, show list of accounts 2023-06-22 11:30:43 +02:00
Ivan Habunek 88ceaf09ae
Remove travis from readme and docs 2023-06-22 11:24:05 +02:00
Ivan Habunek 70c9eec55e
Merge pull request #357 2023-06-22 11:21:19 +02:00
Ivan Habunek dc0d69f14b
Add changelog 2023-06-22 11:21:04 +02:00
Ivan Habunek 51d60679c3
Simplify account timeline options 2023-06-22 11:16:32 +02:00
Daniel Schwarz f7ba208d3b
Added personal timeline, which is your own timeline of posts
Fixes issue #354
2023-06-22 11:07:54 +02:00
Daniel Schwarz e55474158a
Added toot timeline --account_tl console command
Displays the timeline of any account (public timeline for
users who are not you, public+private for you)

Right now it does not display reblogs or replies,
this could be configurable if we add more command line flags
2023-06-22 11:07:54 +02:00
Ivan Habunek 4df2abf5cd
Implement base_url as fixture 2023-06-22 10:59:44 +02:00
Daniel Schwarz 56cc056639 Login to servers that don't honor the uri spec for V1::Instance
Pleroma, Akkoma, and other servers do not follow the Mastodon spec
for the 'uri' attribute which specifies that it contains the domain
name of the instance. Instead, they return a complete URI.

As a workaround, we now detect this situation and parse out the
domain from the URI when necessary. This fixes issue #347.

Thanks to @laleanor for their patch and @rjp for ideas on how to
make it work with GotoSocial and other servers
2023-05-27 05:51:00 +02:00
Vítor Galvão 6ce728e020 README.rst: Fix image links 2023-04-18 21:04:16 +02:00
Ivan Habunek a388c30edf
Remove unused import 2023-04-06 21:36:09 +02:00
Ivan Habunek cee289a391
Fix integration test skip logic 2023-04-06 21:35:12 +02:00
Ivan Habunek 5cb8967c84
Simplify integration tests by catching ConsoleError 2023-03-30 12:44:32 +02:00
Ivan Habunek e3394c1693
Extract fetching list ID
Also don't check if account is found, that function alredy raises a
ConsoleError.
2023-03-30 12:31:04 +02:00
Ivan Habunek c659ed7a5d
Fix tests 2023-03-30 12:09:38 +02:00
Ivan Habunek 2166918da2
Merge pull request #341 2023-03-30 12:01:56 +02:00
Ivan Habunek a19670d0c3
Add integration tests for lists 2023-03-30 12:00:12 +02:00
Ivan Habunek 8624ddb175
Add a simple table printer and apply to lists 2023-03-30 12:00:12 +02:00
Ivan Habunek 3d7f7cfce3
Break up integration tests 2023-03-30 10:56:40 +02:00
Daniel Schwarz e85f7ce594
Give a more specfic error message if we can't add an account to list 2023-03-30 10:03:45 +02:00
Daniel Schwarz 47b182a05b
Changed parameters for list cmds
Title is now a positional parameter.

Also added some error handling in the command processing
for looking up list IDs per @ihabunek 's suggestions
2023-03-30 10:03:34 +02:00
Dan Schwarz c12c53d719
minor improvement of feedback messages 2023-03-30 10:01:04 +02:00
Dan Schwarz 2e0f2548e6
Added toot list_remove_account command 2023-03-30 10:01:04 +02:00
Dan Schwarz 80f05e8147
Added toot list_add_account command 2023-03-30 10:01:03 +02:00
Daniel Schwarz bfdd84870f
Added "toot list_delete" and "toot list_create" commands 2023-03-30 10:01:03 +02:00
Daniel Schwarz 08bb7aae71
added "toot list_accounts" command 2023-03-30 10:01:03 +02:00
Daniel Schwarz 855b2a1526
"toot list" console command added 2023-03-30 10:01:02 +02:00
Dan Schwarz 6bcd43a6ae
Add basic support for Mastodon Lists
Fixes issue #255
2023-03-30 10:01:02 +02:00
Dan Schwarz 040427884b
Make button widget unicode-aware (spacing) 2023-03-30 10:01:02 +02:00
Dan Schwarz 39e1f92970
add get_lists method 2023-03-30 10:01:01 +02:00
Dan Schwarz 4c12a210c4
Ignore warning W503
see: https://www.flake8rules.com/rules/W503.html for justification
2023-03-30 10:01:01 +02:00
Ivan Habunek cb0af3488b
Merge pull request #344 from danschwarz/boost_fix
Fix for boosting of statuses that were previously boosted by others
2023-03-19 08:26:54 +01:00
Ivan Habunek 0557a3cd69
Merge pull request #342 from ihabunek/timeline
Don't use signals
2023-03-19 08:18:06 +01:00
Daniel Schwarz 71da194b76 Fix for boosting of statuses that were previously boosted by others
These wouldn't boost/unboost correctly; now they do.
2023-03-17 18:45:06 -04:00
Ivan Habunek 6d60ecee4d
Use direct invocation istead of signals 2023-03-15 10:17:35 +01:00
Ivan Habunek 7726b01000
Add type annotations 2023-03-15 09:55:50 +01:00
Ivan Habunek 45fd2901de
Remove no longer needed args to Timeline
Read them directly from TUI
2023-03-15 09:54:18 +01:00
Ivan Habunek 6047a51c4f
Pass the TUI object to Timeline in constructor 2023-03-15 09:53:56 +01:00
Ivan Habunek 71a2520198
Extract refresh timeline code 2023-03-13 13:37:02 +01:00
Daniel Schwarz 9999d975b4
Fixed refresh so it stays on the currently selected timeline
Fixes issue #337
2023-03-13 13:28:34 +01:00
Ivan Habunek 9006517cc7
Merge pull request #339 from danschwarz/goto_redesign
Restructured Goto Menu items for clarity, added error display
2023-03-13 13:18:07 +01:00
Dan Schwarz dfa3cf1b68 Restructured Goto Menu items for clarity, added error display 2023-03-12 22:00:05 -04:00
Dan Schwarz 99060d221b
Basic support for followed accounts in TUI
In the status detail window, followed accounts are shown in
yellow, while unfollowed accounts are shown in grey.
2023-03-09 12:24:54 +01:00
Ivan Habunek 8b45840d9f
Update changelog, bump version 2023-03-09 11:54:19 +01:00
Ivan Habunek 1098f63170
Merge pull request #336 from ihabunek/poll
Add polls to toot post
2023-03-09 11:03:17 +01:00
Ivan Habunek f746b1043f
Fix sporadically failing test 2023-03-09 11:02:16 +01:00
Ivan Habunek 47c16b01ad
Add poll options to toot post 2023-03-09 11:02:15 +01:00
Ivan Habunek 1e3d59fee2
Don't prompt for toot text if media is given
This allows you to post an image without text:

  toot post --media image.png

And not get prompted to write some text, which seems desirable.
2023-03-09 11:02:15 +01:00
Ivan Habunek a282bc3c0b
Merge pull request #335 from ihabunek/local-domain
Add support for custom instance domains
2023-03-09 11:01:50 +01:00
Ivan Habunek ae882d4958
Fix tests 2023-03-09 11:00:46 +01:00
Ivan Habunek d5b5c89996
Add support for custom instance domains
The instance domain can be different from their base url, for example
the instance at https://social.vivaldi.net uses the vivaldi.net domain,
sans 'social'.

This commit requires the user to provide the base url of the instance,
instead of domain name. The domain is then fetched from the server.

fixes #217
2023-03-09 11:00:45 +01:00
Ivan Habunek 9baa0823f9
Fix formatting 2023-03-07 11:09:41 +01:00
Daniel Schwarz 6b432b2fd3
Deduplicate and sort links for the show links function 2023-03-07 11:06:10 +01:00
Dan Schwarz 30792a0cab
Properly reference status.original when enumerating status links
Fixes #322
2023-03-07 11:06:10 +01:00
Ivan Habunek 9d51546be3
Merge pull request #334 from danschwarz/osc52
Add cop[y] status feature - copies status text to clipboard
2023-03-07 11:01:50 +01:00
Daniel Schwarz 04615e84bc Add cop[y] status feature - copies status text to clipboard
This relies on the OSC 52 terminal feature, which is widely
supported (Windows Terminal, iTerm2, XTerm, Kitty, others)
2023-03-06 19:30:54 -05:00
Ivan Habunek e10bb0ebae
Merge pull request #328 from danschwarz/translate_fix
Fix for issue #318 translation of boosted toots doesn't work
2023-03-06 07:57:31 +01:00
Ivan Habunek 04beac87ed
Merge pull request #330 from rogarb/conversations
Add conversations support in TUI
2023-03-05 11:34:18 +01:00
Ivan Habunek 092511b1fe
Merge pull request #332 from rogarb/reply_default_visibility
Inherit visibility when replying in TUI
2023-03-05 10:51:04 +01:00
rogarb d81eaaad0d
Inherit visibility when replying in TUI
When replying to a toot, the visibility gets set to default: if the toot
has a direct visibility, the visibility in reply will be changed to the
default one, whereas it should stay in direct mode.
2023-03-05 09:45:46 +01:00
rogarb 60029a442e
Add conversations support in TUI 2023-03-04 23:33:51 +01:00
Dan Schwarz 27088b1219 Fixed translation of boosted toots 2023-03-04 16:04:13 -05:00
Ivan Habunek 314a0aefbe
Merge pull request #326 from ihabunek/thumbs
Add media thumbnails
2023-03-04 17:04:51 +01:00
Ivan Habunek 3cb548ae3a
Document how to generate docs 2023-03-03 17:12:59 +01:00
Ivan Habunek e77c40e27c
Update references to new documentation 2023-03-03 16:54:40 +01:00
Ivan Habunek 32d65a1615
Add new mdbook docs 2023-03-03 13:55:20 +01:00
Ivan Habunek 33fb3c790d
Remove remnants of old docs 2023-03-03 13:51:57 +01:00
Ivan Habunek 5bd07e1756
Remove readthedocs documentation 2023-03-03 13:46:31 +01:00
Ivan Habunek e2f9a354ac
Test media thumbnails 2023-03-03 11:44:40 +01:00
Ivan Habunek 244502ec0b
Wait until media uploads finish processing 2023-03-03 11:44:40 +01:00
Ivan Habunek e26cb52fd7
Enable providing media thumbnails
issue #301
2023-03-03 11:44:39 +01:00
Ivan Habunek 14a286ef0d
Use v2 endpoint to upload media
v1 is deprecated
2023-03-03 09:03:33 +01:00
Ivan Habunek 7ed28b3bc3
Add helper function for stripping None values 2023-03-02 11:28:24 +01:00
Ivan Habunek 57824058e0
Bump version, add changelog 2023-03-01 08:09:10 +01:00
Ivan Habunek 882b6c4d0a
Minor tweaks 2023-03-01 07:51:36 +01:00
Ivan Habunek c3c640edc3
Merge pull request #320 from danschwarz/notif
Basic support for viewing Status and Mention notifs as a timeline
2023-03-01 07:51:27 +01:00
Daniel Schwarz 8ee414c83a Make _notif_timeline_generator more robust
Notifications without statuses shouldn't crash
2023-02-24 22:51:27 -05:00
Daniel Schwarz 744dc090b4 Basic support for viewing Status and Mention notifs as a timeline
Now the Goto Menu has a < Notifications > button. This will load
your Status and Mention notifications as a standard timeline
for viewing.
2023-02-24 22:19:24 -05:00
Ivan Habunek 48770a3120
Merge pull request #317 from danschwarz/action_fix
Account overlay - don't allow mute/block/unfollow own account
2023-02-22 08:41:54 +01:00
Ivan Habunek e370d76913
Merge pull request #315 from ihabunek/account-update
Account update
2023-02-22 08:41:08 +01:00
Daniel Schwarz 3a72d5f261 Account overlay - don't allow mute/block/unfollow own account 2023-02-21 20:43:09 -05:00
Ivan Habunek 62e3b2c66d
Add integration tests for updating user account 2023-02-21 18:10:34 +01:00
Ivan Habunek 6c5eb528ee
Add command for updating user account
fixes #314
2023-02-21 18:10:14 +01:00
Ivan Habunek 635dcd38cf
Backport argparse BooleanOptionalAction 2023-02-21 18:08:30 +01:00
Ivan Habunek 00baabf7aa
Store temp file when using editor to post
In case of failed posting the status is not lost and the user can
recover it and continue posting.

fixes #311
2023-02-20 19:54:47 +01:00
Ivan Habunek bd8ec053b6
Merge branch 'account_actions' 2023-02-20 09:15:22 +01:00
Daniel Schwarz 85df15f533
Implement actions for Account
Actions: un/follow, un/mute, un/block are invoked synchronously
and the Account overlay window is updated to reflect the changes.
2023-02-20 09:11:36 +01:00
Daniel Schwarz 876ad1f53d
Added framework for action buttons with "Confirm/Cancel" behavior
Buttons with confirm/cancel behavior are in place and working
TBD: implement actions when user gives confirmation.
2023-02-20 09:11:36 +01:00
Daniel Schwarz a1490461bd
Add relationship support to Account overlay
Display relationship details such as followed_by, blocked_by,
and add buttons to un/follow, un/mute, un/block.
Buttons are nonfunctional for now
2023-02-20 09:11:34 +01:00
Daniel Schwarz 95473fcd6e
Add get_relationship method to api 2023-02-20 09:08:53 +01:00
Ivan Habunek a633f757b5
Merge pull request #312 from danschwarz/poll3
UI to vote in polls
2023-02-20 09:06:51 +01:00
Ivan Habunek 4b87f7876c
Add changelog, bump version 2023-02-20 08:43:27 +01:00
Daniel Schwarz c944863f3f Changed bookmark symbol from unicode glyph to 'b'
The glyph previously used '🠷' is interpreted with different
widths on different terminal emulators (i.e. xterm gets it wrong)
This results in visual glitches in the status detail area and
especially in the scrollbar when visible.  Changing it to a
standard ASCII 'b' fixes all these issues.
2023-02-17 09:34:57 +01:00
Daniel Schwarz 80e711a3a1 Fixed type error in vote method signature 2023-02-15 21:50:50 -05:00
Daniel Schwarz 68cadd4053 Boosted polls weren't working; this change fixes that. 2023-02-15 10:15:16 -05:00
Daniel Schwarz a937650894 Update status detail after leaving overlay
This is currently used for poll voting; after voting in a
poll overlay, the status detail will update with the user's
vote and the new vote count when they dismiss the overlay.

As part of this change, the refresh_status_detail method now
maintains the scroll position after refresh, rather than
scrolling back to the top automatically
2023-02-14 23:16:26 -05:00
Daniel Schwarz 63bc11316b Removed unnecessary check that poll exists 2023-02-14 22:40:35 -05:00
Daniel Schwarz b2a0bc5634 fixed method signature for vote (added list type of int) 2023-02-14 22:40:06 -05:00
Daniel Schwarz ce34a2f05e Added api error message reporting, removed unnecessary parenthesis 2023-02-14 22:21:04 -05:00
Daniel Schwarz 41a8c33409 Removed unneeded parenthesis 2023-02-14 22:21:04 -05:00
Daniel Schwarz ef2c35eaee Removed unneeded pareenthesis 2023-02-14 22:21:04 -05:00
Daniel Schwarz 3b67c85dfc Added styled radio buttons and checkboxes 2023-02-14 22:21:04 -05:00
Ivan Habunek 3a9e4956a2
Don't break if edited_at does not exist
Introduced in mastodon 4.

fixes #309
2023-02-12 09:41:26 +01:00
Daniel Schwarz d399eec6f5 Implement [P]oll command to view/vote on poll
Note that this change also switches some key meanings:
E -> X for View E[x]ception
P -> E for Sav[e] Timeline
to accomodate P for [P]oll
2023-02-10 21:48:42 -05:00
Daniel Schwarz c2faa7e2b6 Add signal and code to invoke poll overlay UI
Note that this change also switches some key meanings:
E -> X for View E[x]ception
P -> E for Sav[e] Timeline
to accomodate P for [P]oll
2023-02-10 21:46:27 -05:00
Daniel Schwarz 44c8460a53 Poll view/vote functionality in an overlay 2023-02-10 21:45:15 -05:00
Daniel Schwarz b50fb9d9fd Added vote method to api 2023-02-10 21:44:37 -05:00
Ivan Habunek ed1aea9cba
Cleanup logging, remove color 2023-02-04 09:01:56 +01:00
Ivan Habunek f52fe00d26
Pass params as dict 2023-02-04 09:01:48 +01:00
Ivan Habunek 6a6225caa5
Update markdown changelog 2023-02-03 15:12:10 +01:00
Ivan Habunek f4f3b0d64f
Update changelog 2023-02-03 15:10:27 +01:00
Ivan Habunek 1c59facd2b
Show account of original toot instead of reblog 2023-02-03 15:03:06 +01:00
Ivan Habunek 9e2b5acf49
Extract link helper 2023-02-03 15:03:05 +01:00
Ivan Habunek 6a54f3dd0e
Pass account as argument 2023-02-03 15:03:05 +01:00
Ivan Habunek 0e535a3fd0
Make tags a bit less verbose 2023-02-03 15:03:05 +01:00
Ivan Habunek 83318ca43b
Add changelog, bump version 2023-02-03 15:03:05 +01:00
Daniel Schwarz de7abd0b68 Strip trailing ":" off field names for consistency
Some servers add them, some don't. This makes the names
consistent before adding one trailing :
2023-02-03 15:02:47 +01:00
Daniel Schwarz 5943ebda3a Show if an account is a "bot", "locked", or "suspended" 2023-02-03 15:02:47 +01:00
Daniel Schwarz a5d61c04a7 Account overlay is now at feature parity with toot whois command 2023-02-03 15:02:47 +01:00
Daniel Schwarz e10fff983f Support for "account" message - load details and display overlay 2023-02-03 15:02:47 +01:00
Daniel Schwarz 7be30302dc Timeline ui support + signal for [A]ccount feature
[A] displays overlay with account details and actions
2023-02-03 15:02:47 +01:00
Daniel Schwarz 5ae6db8689 Added 'whois' API method to retrieve account by ID
More reliable and faster than retrieving by account name
tbd: is "whois" the right name for this method?
2023-02-03 15:02:47 +01:00
Daniel Schwarz 4336871e9f Move save button to top 2023-02-03 04:09:35 +01:00
Ivan Habunek 9f3a54c054 Add status id to default filename 2023-02-03 04:09:35 +01:00
Ivan Habunek 37b4f00f6e Use get_edit_text to avoid trimming
This method returns only the text entered, without the caption.
2023-02-03 04:09:35 +01:00
Ivan Habunek 5606d95c93 Put edit box and button in list box with source
This makes it much easier to focus them, just scroll down.
2023-02-03 04:09:35 +01:00
Ivan Habunek cc1809db31 Remove splitlines, not needed
Works just as well if provided as a single Text object.
2023-02-03 04:09:35 +01:00
Ivan Habunek 1b2da81d15 Inline button construction, avoid lambda 2023-02-03 04:09:35 +01:00
Ivan Habunek c7f9f06f72 Use context for writing to file 2023-02-03 04:09:35 +01:00
Ivan Habunek 61990822e5 Use full words instead of abbreviations 2023-02-03 04:09:35 +01:00
Daniel Schwarz b9a68a6132 Adds a "save" option to the so[u]rce dialog. 2023-02-03 04:09:35 +01:00
Ivan Habunek 71d3bbd91f
Fix tests 2023-02-02 09:03:12 +01:00
Daniel Schwarz fe5b9d1a46 React properly to 422: Validation Failed. Status has already been taken errors 2022-12-13 12:45:07 -05:00
Denis Laxalde 2199ca18b5 Document the [M]edia action 2020-01-26 11:23:39 +01:00
109 zmienionych plików z 9456 dodań i 4087 usunięć

Wyświetl plik

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

Wyświetl plik

@ -4,12 +4,10 @@ on: [push, pull_request]
jobs:
test:
# Older Ubuntu required for testing on Python 3.6 which is not available in
# later versions. Remove once support for 3.6 is dropped.
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
strategy:
matrix:
python-version: ["3.6", "3.7", "3.8", "3.9", "3.10", "3.11"]
python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v3
@ -20,14 +18,13 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e .
pip install -r requirements-test.txt
pip install -e ".[test,richtext]"
- name: Run tests
run: |
pytest
- name: Validate minimum required version
run: |
vermin --target=3.6 --no-tips .
vermin toot
- name: Check style
run: |
flake8

11
.gitignore vendored
Wyświetl plik

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

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

Wyświetl plik

@ -3,6 +3,129 @@ Changelog
<!-- Do not edit. This file is automatically generated from changelog.yaml.-->
**0.41.1 (2024-01-02)**
* Fix a crash in settings parsing code
**0.41.0 (2024-01-02)**
* Honour user's default visibility set in Mastodon preferences instead of always
defaulting to public visibility (thanks Lexi Winter)
* TUI: Add editing toots (thanks Lexi Winter)
* TUI: Fix a bug which made palette config in settings not work
* TUI: Show edit datetime in status detail (thanks Lexi Winter)
**0.40.2 (2023-12-28)**
* Reinstate `toot post --using` option.
* Add shell completion for instances.
**0.40.1 (2023-12-28)**
* Add `toot --as` option to replace `toot post --using`. This now works for all
commands.
**0.40.0 (2023-12-27)**
This release includes a rather extensive change to use the Click library
(https://click.palletsprojects.com/) for creating the command line interface.
This allows for some new features like nested commands, setting parameters via
environment variables, and shell completion. Backward compatibility should be
mostly preserved, except for cases noted below. Please report any issues.
* BREAKING: Remove deprecated `--disable-https` option for `login` and
`login_cli`, pass the base URL instead
* BREAKING: Options `--debug` and `--color` must be specified after `toot` but
before the command
* BREAKING: Option `--quiet` has been removed. Redirect output instead.
* Add passing parameters via environment variables, see:
https://toot.bezdomni.net/environment_variables.html
* Add shell completion, see: https://toot.bezdomni.net/shell_completion.html
* Add `tags info`, `tags featured`, `tags feature`, and `tags unfeature`
commands
* Add `tags followed`, `tags follow`, and `tags unfollow` sub-commands,
deprecate `tags_followed`, `tags_follow`, and `tags tags_unfollow`
* Add `lists accounts`, `lists add`, `lists create`, `lists delete`, `lists
list`, `lists remove` subcommands, deprecate `lists`, `lists_accounts`,
`lists_add`, `lists_create`, `lists_delete`, `lists_remove` commands.
* Add `--json` option to tags and lists commands
* Add `toot --width` option for setting your preferred terminal width
* Add `--media-viewer` and `--colors` options to `toot tui`. These were
previously accessible only via settings.
* TUI: Fix issue where UI did not render until first input (thanks Urwid devs)
**0.39.0 (2023-11-23)**
* Add `--json` option to many commands, this makes them print the JSON data
returned by the server instead of human-readable data. Useful for scripting.
* TUI: Make media viewer configurable in settings, see:
https://toot.bezdomni.net/settings.html#tui-view-images
* TUI: Add rich text rendering (thanks Dan Schwarz)
**0.38.2 (2023-11-16)**
* Fix compatibility with Pleroma (#399, thanks Sandra Snan)
* Fix language documentation (thanks Sandra Snan)
**0.38.1 (2023-07-25)**
* Fix relative datetimes option in TUI
**0.38.0 (2023-07-25)**
* Add `toot muted` and `toot blocked` commands (thanks Florian Obser)
* Add settings file, allows setting common options, defining defaults for
command arguments, and the TUI palette
* TUI: Remap shortcuts so they don't override HJKL used for navigation (thanks
Dan Schwarz)
**0.37.0 (2023-06-28)**
* **BREAKING:** Require Python 3.7+
* Add `timeline --account` option to show the account timeline (thanks Dan
Schwarz)
* Add `toot status` command to show a single status
* TUI: Add personal timeline (thanks Dan Schwarz)
* TUI: Highlight followed accounts in status details (thanks Dan Schwarz)
* TUI: Restructured goto menu (thanks Dan Schwarz)
* TUI: Fix boosting boosted statuses (thanks Dan Schwarz)
* TUI: Add support for list timelines (thanks Dan Schwarz)
**0.36.0 (2023-03-09)**
* Move docs from toot.readthedocs.io to toot.bezdomni.net
* Add specifying media thumbnails to `toot post` (#301)
* Add creating polls to `toot post`
* Handle custom instance domains (e.g. when server is located at
`social.vivaldi.net`, but uses the `vivaldi.net` mastodon domain. (#217)
* TUI: Inherit post visibility when replying (thanks @rogarb)
* TUI: Add conversations timeline (thanks @rogarb)
* TUI: Add shortcut to copy toot contents (thanks Dan Schwarz)
**0.35.0 (2023-03-01)**
* Save toot contents when using --editor so it's recoverable if posting fails
(#311)
* TUI: Add voting on polls (thanks Dan Schwarz)
* TUI: Add following/blocking/muting accounts (thanks Dan Schwarz)
* TUI: Add notifications timeline (thanks Dan Schwarz)
**0.34.1 (2023-02-20)**
* TUI: Fix bug where TUI would break on older Mastodon instances (#309)
**0.34.0 (2023-02-03)**
* Fix Python version detection which would fail in some cases (thanks K)
* Fix toot --help not working (thanks Norman Walsh)
* TUI: Add option to save status JSON data from source window (thanks Dan
Schwarz)
* TUI: Add `--relative-datetimes` option to show relative datetimes (thanks Dan
Schwarz)
* TUI: Don't focus newly created post (#188, thanks Dan Schwarz)
* TUI: Add ability to scroll long status messages (#166, thanks Dan Schwarz)
* TUI: Add action to view account details (thanks Dan Schwarz)
**0.33.1 (2023-01-03)**
* TUI: Fix crash when viewing toot in browser
@ -21,7 +144,7 @@ Changelog
* TUI: Hide polls, cards and media attachments for sensitive posts (thanks
Daniel Schwarz)
* TUI: Add bookmarking and bookmark timeline (thanks Daniel Schwarz)
* TUI: Show status visiblity (thanks Lim Ding Wen)
* TUI: Show status visibility (thanks Lim Ding Wen)
* TUI: Reply to original account instead of boosting account (thanks Lim Ding
Wen)
* TUI: Refresh screen after exiting browser, required for text browsers (thanks
@ -49,7 +172,7 @@ Changelog
**0.30.1 (2022-11-30)**
* Remove usage of depreacted `text_url` status field. Fixes posting media
* Remove usage of deprecated `text_url` status field. Fixes posting media
without text.
**0.30.0 (2022-11-29)**
@ -102,7 +225,7 @@ Changelog
(#168)
* Add `--reverse` option to `toot notifications` (#151)
* Fix `toot timeline` to respect `--instance` option
* TUI: Add opton to pin/save tag timelines (#163, thanks @dlax)
* TUI: Add option to pin/save tag timelines (#163, thanks @dlax)
* TUI: Fixed crash on empty timeline (#138, thanks ecs)
**0.26.0 (2020-04-15)**
@ -113,7 +236,7 @@ Changelog
* **IMPORTANT:** Starting from this release, new releases will not be uploaded
to the APT package repository at `bezdomni.net`. Please use the official
Debian or Ubuntu repos or choose another [installation
option](https://toot.readthedocs.io/en/latest/install.html).
option](https://toot.bezdomni.net/installation.html).
**0.25.2 (2020-01-23)**

Wyświetl plik

@ -5,7 +5,7 @@ Firstly, thank you for contributing to toot!
Relevant links which will be referenced below:
* [toot documentation](https://toot.readthedocs.io/)
* [toot documentation](https://toot.bezdomni.net/)
* [toot-discuss mailing list](https://lists.sr.ht/~ihabunek/toot-discuss)
used for discussion as well as accepting patches
* [toot project on github](https://github.com/ihabunek/toot)
@ -77,8 +77,9 @@ pip install -r requirements-dev.txt
pip install -r requirements-test.txt
```
While the virtual env is active, running `toot` will execute the one you checked
out. This allows you to make changes and test them.
While the virtual env is active, you can run `./_env/bin/toot` to
execute the one you checked out. This allows you to make changes and
test them.
#### Crafting good commits
@ -110,7 +111,7 @@ these rules for you.
#### Run tests before submitting
You can run code and sytle tests by running:
You can run code and style tests by running:
```
make test

Wyświetl plik

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

Wyświetl plik

@ -6,8 +6,6 @@ Toot - a Mastodon CLI client
Toot is a CLI and TUI tool for interacting with Mastodon instances from the command line.
.. image:: https://img.shields.io/travis/ihabunek/toot.svg?maxAge=3600&style=flat-square
:target: https://travis-ci.org/ihabunek/toot
.. image:: https://img.shields.io/badge/author-%40ihabunek-blue.svg?maxAge=3600&style=flat-square
:target: https://mastodon.social/@ihabunek
.. image:: https://img.shields.io/github/license/ihabunek/toot.svg?maxAge=3600&style=flat-square
@ -20,7 +18,7 @@ Resources
* Homepage: https://github.com/ihabunek/toot
* Issues: https://github.com/ihabunek/toot/issues
* Documentation: https://toot.readthedocs.io/en/latest/
* Documentation: https://toot.bezdomni.net/
* Mailing list for discussion, support and patches:
https://lists.sr.ht/~ihabunek/toot-discuss
* Informal discussion: #toot IRC channel on `libera.chat <https://libera.chat/>`_
@ -39,9 +37,9 @@ Terminal User Interface
toot includes a terminal user interface (TUI). Run it with ``toot tui``.
.. image :: https://raw.githubusercontent.com/ihabunek/toot/master/docs/_static/tui_list.png
.. image :: https://raw.githubusercontent.com/ihabunek/toot/master/docs/images/tui_list.png
.. image :: https://raw.githubusercontent.com/ihabunek/toot/master/docs/_static/tui_compose.png
.. image :: https://raw.githubusercontent.com/ihabunek/toot/master/docs/images/tui_compose.png
License

13
book.css 100644
Wyświetl plik

@ -0,0 +1,13 @@
/* Overrides for the docs theme */
table { width: 100% }
table th { text-align: left }
code { white-space: pre }
h2, h3 { margin-top: 2.5rem; }
h4, h5 { margin-top: 2rem; }
td.code {
font-family: "Source Code Pro", Consolas, "Ubuntu Mono", Menlo, "DejaVu Sans Mono", monospace, monospace !important;
font-size: 0.875em;
width: 20%;
white-space: nowrap;
}

13
book.toml 100644
Wyświetl plik

@ -0,0 +1,13 @@
[book]
authors = ["Ivan Habunek"]
language = "en"
multilingual = false
src = "docs"
title = "toot"
[output.html]
additional-css = ["book.css"]
[preprocessor.toc]
command = "mdbook-toc"
renderer = ["html"]

Wyświetl plik

@ -1,3 +1,122 @@
0.41.1:
date: 2024-01-02
changes:
- "Fix a crash in settings parsing code"
0.41.0:
date: 2024-01-02
changes:
- "Honour user's default visibility set in Mastodon preferences instead of always defaulting to public visibility (thanks Lexi Winter)"
- "TUI: Add editing toots (thanks Lexi Winter)"
- "TUI: Fix a bug which made palette config in settings not work"
- "TUI: Show edit datetime in status detail (thanks Lexi Winter)"
0.40.2:
date: 2023-12-28
changes:
- "Reinstate `toot post --using` option."
- "Add shell completion for instances."
0.40.1:
date: 2023-12-28
changes:
- "Add `toot --as` option to replace `toot post --using`. This now works for all commands."
0.40.0:
date: 2023-12-27
description: |
This release includes a rather extensive change to use the Click library
(https://click.palletsprojects.com/) for creating the command line
interface. This allows for some new features like nested commands, setting
parameters via environment variables, and shell completion. Backward
compatibility should be mostly preserved, except for cases noted below.
Please report any issues.
changes:
- "BREAKING: Remove deprecated `--disable-https` option for `login` and `login_cli`, pass the base URL instead"
- "BREAKING: Options `--debug` and `--color` must be specified after `toot` but before the command"
- "BREAKING: Option `--quiet` has been removed. Redirect output instead."
- "Add passing parameters via environment variables, see: https://toot.bezdomni.net/environment_variables.html"
- "Add shell completion, see: https://toot.bezdomni.net/shell_completion.html"
- "Add `tags info`, `tags featured`, `tags feature`, and `tags unfeature` commands"
- "Add `tags followed`, `tags follow`, and `tags unfollow` sub-commands, deprecate `tags_followed`, `tags_follow`, and `tags tags_unfollow`"
- "Add `lists accounts`, `lists add`, `lists create`, `lists delete`, `lists list`, `lists remove` subcommands, deprecate `lists`, `lists_accounts`, `lists_add`, `lists_create`, `lists_delete`, `lists_remove` commands."
- "Add `--json` option to tags and lists commands"
- "Add `toot --width` option for setting your preferred terminal width"
- "Add `--media-viewer` and `--colors` options to `toot tui`. These were previously accessible only via settings."
- "TUI: Fix issue where UI did not render until first input (thanks Urwid devs)"
0.39.0:
date: 2023-11-23
changes:
- "Add `--json` option to many commands, this makes them print the JSON data returned by the server instead of human-readable data. Useful for scripting."
- "TUI: Make media viewer configurable in settings, see: https://toot.bezdomni.net/settings.html#tui-view-images"
- "TUI: Add rich text rendering (thanks Dan Schwarz)"
0.38.2:
date: 2023-11-16
changes:
- "Fix compatibility with Pleroma (#399, thanks Sandra Snan)"
- "Fix language documentation (thanks Sandra Snan)"
0.38.1:
date: 2023-07-25
changes:
- "Fix relative datetimes option in TUI"
0.38.0:
date: 2023-07-25
changes:
- "Add `toot muted` and `toot blocked` commands (thanks Florian Obser)"
- "Add settings file, allows setting common options, defining defaults for command arguments, and the TUI palette"
- "TUI: Remap shortcuts so they don't override HJKL used for navigation (thanks Dan Schwarz)"
0.37.0:
date: 2023-06-28
changes:
- "**BREAKING:** Require Python 3.7+"
- "Add `timeline --account` option to show the account timeline (thanks Dan Schwarz)"
- "Add `toot status` command to show a single status"
- "TUI: Add personal timeline (thanks Dan Schwarz)"
- "TUI: Highlight followed accounts in status details (thanks Dan Schwarz)"
- "TUI: Restructured goto menu (thanks Dan Schwarz)"
- "TUI: Fix boosting boosted statuses (thanks Dan Schwarz)"
- "TUI: Add support for list timelines (thanks Dan Schwarz)"
0.36.0:
date: 2023-03-09
changes:
- "Move docs from toot.readthedocs.io to toot.bezdomni.net"
- "Add specifying media thumbnails to `toot post` (#301)"
- "Add creating polls to `toot post`"
- "Handle custom instance domains (e.g. when server is located at `social.vivaldi.net`, but uses the `vivaldi.net` mastodon domain. (#217)"
- "TUI: Inherit post visibility when replying (thanks @rogarb)"
- "TUI: Add conversations timeline (thanks @rogarb)"
- "TUI: Add shortcut to copy toot contents (thanks Dan Schwarz)"
0.35.0:
date: 2023-03-01
changes:
- "Save toot contents when using --editor so it's recoverable if posting fails (#311)"
- "TUI: Add voting on polls (thanks Dan Schwarz)"
- "TUI: Add following/blocking/muting accounts (thanks Dan Schwarz)"
- "TUI: Add notifications timeline (thanks Dan Schwarz)"
0.34.1:
date: 2023-02-20
changes:
- "TUI: Fix bug where TUI would break on older Mastodon instances (#309)"
0.34.0:
date: 2023-02-03
changes:
- "Fix Python version detection which would fail in some cases (thanks K)"
- "Fix toot --help not working (thanks Norman Walsh)"
- "TUI: Add option to save status JSON data from source window (thanks Dan Schwarz)"
- "TUI: Add `--relative-datetimes` option to show relative datetimes (thanks Dan Schwarz)"
- "TUI: Don't focus newly created post (#188, thanks Dan Schwarz)"
- "TUI: Add ability to scroll long status messages (#166, thanks Dan Schwarz)"
- "TUI: Add action to view account details (thanks Dan Schwarz)"
0.33.1:
date: 2023-01-03
changes:
@ -14,7 +133,7 @@
- "TUI: Show an error if attemptint to boost a private status (thanks Lim Ding Wen)"
- "TUI: Hide polls, cards and media attachments for sensitive posts (thanks Daniel Schwarz)"
- "TUI: Add bookmarking and bookmark timeline (thanks Daniel Schwarz)"
- "TUI: Show status visiblity (thanks Lim Ding Wen)"
- "TUI: Show status visibility (thanks Lim Ding Wen)"
- "TUI: Reply to original account instead of boosting account (thanks Lim Ding Wen)"
- "TUI: Refresh screen after exiting browser, required for text browsers (thanks Daniel Schwarz)"
- "TUI: Highlight followed tags (thanks Daniel Schwarz)"
@ -42,7 +161,7 @@
0.30.1:
date: 2022-11-30
changes:
- "Remove usage of depreacted `text_url` status field. Fixes posting media without text."
- "Remove usage of deprecated `text_url` status field. Fixes posting media without text."
0.30.0:
date: 2022-11-29
@ -89,7 +208,7 @@
- "TUI: Fix access to public and tag timelines when on private mastodon instances (#168)"
- "Add `--reverse` option to `toot notifications` (#151)"
- "Fix `toot timeline` to respect `--instance` option"
- "TUI: Add opton to pin/save tag timelines (#163, thanks @dlax)"
- "TUI: Add option to pin/save tag timelines (#163, thanks @dlax)"
- "TUI: Fixed crash on empty timeline (#138, thanks ecs)"
0.26.0:
@ -98,7 +217,7 @@
- "Fix datetime parsing on Python 3.5 (#162)"
- "TUI: Display status links and open them (#154, thanks @dlax)"
- "TUI: Fix visibility descriptions (#153, thanks @finnoleary)"
- "**IMPORTANT:** Starting from this release, new releases will not be uploaded to the APT package repository at `bezdomni.net`. Please use the official Debian or Ubuntu repos or choose another [installation option](https://toot.readthedocs.io/en/latest/install.html)."
- "**IMPORTANT:** Starting from this release, new releases will not be uploaded to the APT package repository at `bezdomni.net`. Please use the official Debian or Ubuntu repos or choose another [installation option](https://toot.bezdomni.net/installation.html)."
0.25.2:
date: 2020-01-23

Wyświetl plik

@ -1,23 +0,0 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line.
SPHINXOPTS =
SPHINXBUILD = sphinx-build
SPHINXPROJ = toot
SOURCEDIR = .
BUILDDIR = _build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
serve:
sphinx-autobuild "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

17
docs/SUMMARY.md 100644
Wyświetl plik

@ -0,0 +1,17 @@
# Summary
[Introduction](introduction.md)
- [Installation](installation.md)
- [Usage](usage.md)
- [Advanced](advanced.md)
- [Settings](settings.md)
- [Shell completion](shell_completion.md)
- [Environment variables](environment_variables.md)
- [TUI](tui.md)
- [Contributing](contributing.md)
- [Documentation](documentation.md)
- [Release procedure](release.md)
- [Changelog](changelog.md)
[License](license.md)

Wyświetl plik

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

Wyświetl plik

@ -1,5 +0,0 @@
<h1 class="logo"><a href="{{ pathto(master_doc) }}">{{ project }}</a></h1>
{% if theme_description %}
<p class="blurb">{{ theme_description }}</p>
{% endif %}

Wyświetl plik

@ -1,40 +1,39 @@
==============
Advanced usage
==============
Disabling HTTPS
---------------
You may pass the ``--disable-https`` flag to use unencrypted HTTP instead of
You may pass the `--disable-https` flag to use unencrypted HTTP instead of
HTTPS for a given instance. This is inherently insecure and should be used only
when connecting to local development instances.
.. code-block:: sh
toot login --disable-https --instance localhost:8080
```sh
toot login --disable-https --instance localhost:8080
```
Using proxies
-------------
You can configure proxies by setting the ``HTTPS_PROXY`` or ``HTTP_PROXY``
You can configure proxies by setting the `HTTPS_PROXY` or `HTTP_PROXY`
environment variables. This will cause all http(s) requests to be proxied
through the specified server.
For example:
.. code-block:: sh
export HTTPS_PROXY="http://1.2.3.4:5678"
toot login --instance mastodon.social
```sh
export HTTPS_PROXY="http://1.2.3.4:5678"
toot login --instance mastodon.social
```
**NB:** This feature is provided by
`requests <http://docs.python-requests.org/en/master/user/advanced/#proxies>`_
[requests](http://docs.python-requests.org/en/master/user/advanced/#proxies>)
and setting the environment variable will affect other programs using this
library.
This environment can be set for a single call to toot by prefixing the command
with the environment variable:
.. code-block:: sh
HTTPS_PROXY="http://1.2.3.4:5678" toot login --instance mastodon.social
```
HTTPS_PROXY="http://1.2.3.4:5678" toot login --instance mastodon.social
```

450
docs/changelog.md 100644
Wyświetl plik

@ -0,0 +1,450 @@
Changelog
---------
<!-- Do not edit. This file is automatically generated from changelog.yaml.-->
**0.41.1 (2024-01-02)**
* Fix a crash in settings parsing code
**0.41.0 (2024-01-02)**
* Honour user's default visibility set in Mastodon preferences instead of always
defaulting to public visibility (thanks Lexi Winter)
* TUI: Add editing toots (thanks Lexi Winter)
* TUI: Fix a bug which made palette config in settings not work
* TUI: Show edit datetime in status detail (thanks Lexi Winter)
**0.40.2 (2023-12-28)**
* Reinstate `toot post --using` option.
* Add shell completion for instances.
**0.40.1 (2023-12-28)**
* Add `toot --as` option to replace `toot post --using`. This now works for all
commands.
**0.40.0 (2023-12-27)**
This release includes a rather extensive change to use the Click library
(https://click.palletsprojects.com/) for creating the command line interface.
This allows for some new features like nested commands, setting parameters via
environment variables, and shell completion. Backward compatibility should be
mostly preserved, except for cases noted below. Please report any issues.
* BREAKING: Remove deprecated `--disable-https` option for `login` and
`login_cli`, pass the base URL instead
* BREAKING: Options `--debug` and `--color` must be specified after `toot` but
before the command
* BREAKING: Option `--quiet` has been removed. Redirect output instead.
* Add passing parameters via environment variables, see:
https://toot.bezdomni.net/environment_variables.html
* Add shell completion, see: https://toot.bezdomni.net/shell_completion.html
* Add `tags info`, `tags featured`, `tags feature`, and `tags unfeature`
commands
* Add `tags followed`, `tags follow`, and `tags unfollow` sub-commands,
deprecate `tags_followed`, `tags_follow`, and `tags tags_unfollow`
* Add `lists accounts`, `lists add`, `lists create`, `lists delete`, `lists
list`, `lists remove` subcommands, deprecate `lists`, `lists_accounts`,
`lists_add`, `lists_create`, `lists_delete`, `lists_remove` commands.
* Add `--json` option to tags and lists commands
* Add `toot --width` option for setting your preferred terminal width
* Add `--media-viewer` and `--colors` options to `toot tui`. These were
previously accessible only via settings.
* TUI: Fix issue where UI did not render until first input (thanks Urwid devs)
**0.39.0 (2023-11-23)**
* Add `--json` option to many commands, this makes them print the JSON data
returned by the server instead of human-readable data. Useful for scripting.
* TUI: Make media viewer configurable in settings, see:
https://toot.bezdomni.net/settings.html#tui-view-images
* TUI: Add rich text rendering (thanks Dan Schwarz)
**0.38.2 (2023-11-16)**
* Fix compatibility with Pleroma (#399, thanks Sandra Snan)
* Fix language documentation (thanks Sandra Snan)
**0.38.1 (2023-07-25)**
* Fix relative datetimes option in TUI
**0.38.0 (2023-07-25)**
* Add `toot muted` and `toot blocked` commands (thanks Florian Obser)
* Add settings file, allows setting common options, defining defaults for
command arguments, and the TUI palette
* TUI: Remap shortcuts so they don't override HJKL used for navigation (thanks
Dan Schwarz)
**0.37.0 (2023-06-28)**
* **BREAKING:** Require Python 3.7+
* Add `timeline --account` option to show the account timeline (thanks Dan
Schwarz)
* Add `toot status` command to show a single status
* TUI: Add personal timeline (thanks Dan Schwarz)
* TUI: Highlight followed accounts in status details (thanks Dan Schwarz)
* TUI: Restructured goto menu (thanks Dan Schwarz)
* TUI: Fix boosting boosted statuses (thanks Dan Schwarz)
* TUI: Add support for list timelines (thanks Dan Schwarz)
**0.36.0 (2023-03-09)**
* Move docs from toot.readthedocs.io to toot.bezdomni.net
* Add specifying media thumbnails to `toot post` (#301)
* Add creating polls to `toot post`
* Handle custom instance domains (e.g. when server is located at
`social.vivaldi.net`, but uses the `vivaldi.net` mastodon domain. (#217)
* TUI: Inherit post visibility when replying (thanks @rogarb)
* TUI: Add conversations timeline (thanks @rogarb)
* TUI: Add shortcut to copy toot contents (thanks Dan Schwarz)
**0.35.0 (2023-03-01)**
* Save toot contents when using --editor so it's recoverable if posting fails
(#311)
* TUI: Add voting on polls (thanks Dan Schwarz)
* TUI: Add following/blocking/muting accounts (thanks Dan Schwarz)
* TUI: Add notifications timeline (thanks Dan Schwarz)
**0.34.1 (2023-02-20)**
* TUI: Fix bug where TUI would break on older Mastodon instances (#309)
**0.34.0 (2023-02-03)**
* Fix Python version detection which would fail in some cases (thanks K)
* Fix toot --help not working (thanks Norman Walsh)
* TUI: Add option to save status JSON data from source window (thanks Dan
Schwarz)
* TUI: Add `--relative-datetimes` option to show relative datetimes (thanks Dan
Schwarz)
* TUI: Don't focus newly created post (#188, thanks Dan Schwarz)
* TUI: Add ability to scroll long status messages (#166, thanks Dan Schwarz)
* TUI: Add action to view account details (thanks Dan Schwarz)
**0.33.1 (2023-01-03)**
* TUI: Fix crash when viewing toot in browser
**0.33.0 (2023-01-02)**
* Add CONTRIBUTING.md containing a contribution guide
* Add `env` command which prints local env to include in issues
* Add TOOT_POST_VISIBILITY environment to control default post visibility
(thanks Lim Ding Wen)
* Add `tags_followed`, `tags_follow`, and `tags_unfollow` commands (thanks
Daniel Schwarz)
* Add `tags_bookmarks` command (thanks Giuseppe Bilotta)
* TUI: Show an error if attemptint to boost a private status (thanks Lim Ding
Wen)
* TUI: Hide polls, cards and media attachments for sensitive posts (thanks
Daniel Schwarz)
* TUI: Add bookmarking and bookmark timeline (thanks Daniel Schwarz)
* TUI: Show status visibility (thanks Lim Ding Wen)
* TUI: Reply to original account instead of boosting account (thanks Lim Ding
Wen)
* TUI: Refresh screen after exiting browser, required for text browsers (thanks
Daniel Schwarz)
* TUI: Highlight followed tags (thanks Daniel Schwarz)
**0.32.1 (2022-12-12)**
* Fix packaging issue, missing toot.utils module
**0.32.0 (2022-12-12)**
* TUI: Press N to translate status, if available on your instance (thanks Daniel
Schwarz)
* Fix: `post --language` option now accepts two-letter country code instead of
3-letter. This was changed by mastodon at some point.
* Fix: Failing to find accounts using qualified usernames (#254)
**0.31.0 (2022-12-07)**
* **BREAKING:** Require Python 3.6+
* Add `post --scheduled-in` option for easier scheduling
* Fix posting toots to Pleroma
* Improved testing
**0.30.1 (2022-11-30)**
* Remove usage of deprecated `text_url` status field. Fixes posting media
without text.
**0.30.0 (2022-11-29)**
* Display polls in `timeline` (thanks Daniel Schwarz)
* TUI: Add [,] shortcut to reload timeline (thanks Daniel Schwarz)
* TUI: Add [Z] shortcut to zoom status - allows scrolling (thanks
@PeterFidelman)
* Internals: add integration tests against a local mastodon instance
**0.29.0 (2022-11-21)**
* Add `bookmark` and `unbookmark` commands
* Add `following` and `followers` commands (thanks @Oblomov)
* TUI: Show media attachments in links list (thanks @PeterFidelman)
* Fix tests so that they don't depend on the local timezone
**0.28.1 (2022-11-12)**
* Fix account search to be case insensitive (thanks @TheJokersThief)
* Fix account search to use v2 endpoint, since v1 endpoint was removed on some
instances (thanks @kaja47)
* Add '.toot' extension to temporary files when composing toot in an editor
(thanks @larsks)
* Display localized datetimes in timeline (thanks @mmmmmmbeer)
* Don't use # for comments when composing toot in an editor, since that made it
impossible to post lines starting with #.
* TUI: Fix crash when poll does not have an expiry date
**0.28.0 (2021-08-28)**
* **BREAKING**: Removed `toot curses`, deprecated since 2019-09-03
* Add `--scheduled-at` option to `toot post`, allows scheduling toots
* Add `--description` option to `toot post`, for adding descriptions to media
attachments (thanks @ansuz)
* Add `--mentions` option to `toot notifications` to show only mentions (thanks
@alexwennerberg)
* Add `--content-type` option to `toot post` to allow specifying mime type, used
on Pleroma (thanks Sandra Snan)
* Allow post IDs to be strings as used on Pleroma (thanks Sandra Snan)
* TUI: Allow posts longer than 500 characters if so configured on the server
(thanks Sandra Snan)
* Allow piping the password to login_cli for testing purposes (thanks
@NinjaTrappeur)
* Disable paging timeline when output is piped (thanks @stacyharper)
**0.27.0 (2020-06-15)**
* TUI: Fix access to public and tag timelines when on private mastodon instances
(#168)
* Add `--reverse` option to `toot notifications` (#151)
* Fix `toot timeline` to respect `--instance` option
* TUI: Add option to pin/save tag timelines (#163, thanks @dlax)
* TUI: Fixed crash on empty timeline (#138, thanks ecs)
**0.26.0 (2020-04-15)**
* Fix datetime parsing on Python 3.5 (#162)
* TUI: Display status links and open them (#154, thanks @dlax)
* TUI: Fix visibility descriptions (#153, thanks @finnoleary)
* **IMPORTANT:** Starting from this release, new releases will not be uploaded
to the APT package repository at `bezdomni.net`. Please use the official
Debian or Ubuntu repos or choose another [installation
option](https://toot.bezdomni.net/installation.html).
**0.25.2 (2020-01-23)**
* Revert adding changelog and readme to sourceballs (#149)
* TUI: Fall back to username when display_name is unset (thanks @dlax)
* Note: 0.25.1 was skipped due to error when releasing
**0.25.0 (2020-01-21)**
* TUI: Show character count when composing (#121)
* Include changelog and license in sourceballs (#133)
* Fix searching by hashtag which include the '#' (#134)
* Upgrade search to v2 (#135)
* Fix compatibility with Python < 3.6 (don't use fstrings)
**0.24.0 (2019-09-18)**
* On Windows store config files under %APPDATA%
* CLI: Don't use ANSI colors if not supported by terminal or when not in a tty
* TUI: Implement deleting own status messages
* TUI: Improve rendering of reblogged statuses (thanks @dlax)
* TUI: Set urwid encoding to UTF-8 (thanks @bearzk)
**0.23.1 (2019-09-04)**
* Fix a date parsing bug in Python versions <3.7 (#114)
**0.23.0 (2019-09-03)**
* Add `toot tui`, new and improved TUI implemented written with the help of the
[urwid](http://urwid.org/) library
* Deprecate `toot curses`. It will show a deprecation notice when started. To be
removed in a future release
* Add `--editor` option to `toot post` to allow composing toots in an editor
(#90)
* Fix config file permissions, set them to 0600 when creating the initial config
file (#109)
* Add user agent string to all requests, fixes interaction with instances
protected by Cloudflare (#106)
**0.22.0 (2019-08-01)**
* **BREAKING:** Dropped support for Python 3.3
* Add `toot notifications` to show notifications (thanks @dlax)
* Add posting and replying to curses interface (thanks @Skehmatics)
* Add `--language` option to `toot post`
* Enable attaching upto 4 files via `--media` option on `toot post`
**0.21.0 (2019-02-15)**
* **BREAKING:** in `toot timeline` short argument for selecting a list is no
longer `-i`, this has been changed to select the instance, so that it is the
same as on other commands, please use the long form `--list` instead
* Add `toot reblogged_by` to show who reblogged a status (#88)
* Add `toot thread` to show a status with its replies (#87)
* Better handling of wide characters (eastern scripts, emojis) (#84)
* Improved `timeline`, nicer visuals, and it will now ask to show next batch of
toots, unless given the `--once` option
* Add public/local/tag timelines to `timeline` and `curses`
* Support for boosting and favouriting in `toot curses`, press `f`/`b` (#88,
#93)
**0.20.0 (2019-02-01)**
* Enable interaction with instances using http instead of https (#56)
* Enable proxy usage via environment variables (#47)
* Make `toot post` prompt for input if no text is given (#82)
* Add post-related commands: `favourite`, `unfavourite`, `reblog`, `unreblog`,
`pin` & `unpin` (#75)
**0.19.0 (2018-06-27)**
* Add support for replying to a toot (#6)
* Add `toot delete` command for deleting a toot (#54)
* Add global `--quiet` flag to silence output (#46)
* Make `toot login` provide browser login, and `toot login_cli` log in via
console. This makes it clear what's the preferred option.
* Use Idempotency-Key header to prevent multiple toots being posted if request
is retried
* Fix a bug where all media would be marked as sensitive
**0.18.0 (2018-06-12)**
* Add support for public, tag and list timelines in `toot timeline` (#52)
* Add `--sensitive` and `--spoiler-text` options to `toot post` (#63)
* Curses app improvements (respect sensitive content, require keypress to show,
add help modal, misc improvements)
**0.17.1 (2018-01-15)**
* Create config folder if it does not exist (#40)
* Fix packaging to include `toot.ui` package (#41)
**0.17.0 (2018-01-15)**
* Changed configuration file format to allow switching between multiple logged
in accounts (#32)
* Respect XDG_CONFIG_HOME environment variable to locate config home (#12)
* Dynamically calculate left window width, supports narrower windows (#27)
* Redraw windows when terminal size changes (#25)
* Support scrolling the status list
* Fetch next batch of statuses when bottom is reached
* Support up/down arrows (#30)
* Misc visual improvements
**0.16.2 (2018-01-02)**
* No changes, pushed to fix a packaging issue
**0.16.1 (2017-12-30)**
* Fix bug with app registration
**0.16.0 (2017-12-30)**
* **BREAKING:** Dropped support for Python 2, because it's a pain to support and
caused bugs with handling unicode.
* Remove hacky `login_2fa` command, use `login_browser` instead
* Add `instance` command
* Allow `post`ing media without text (#24)
**0.15.1 (2017-12-12)**
* Fix crash when toot's URL is None (#33), thanks @veer66
**0.15.0 (2017-09-09)**
* Fix Windows compatibility (#18)
**0.14.0 (2017-09-07)**
* Add `--debug` option to enable debug logging instead of using the `TOOT_DEBUG`
environment variable.
* Fix: don't read requirements.txt from setup.py, this fails when packaging deb
and potentially in some other cases (see #18)
**0.13.0 (2017-08-26)**
* Allow passing `--instance` and `--email` to login command
* Add `login_browser` command for proper two factor authentication through the
browser (#19, #23)
**0.12.0 (2017-05-08)**
* Add option to disable ANSI color in output (#15)
* Return nonzero error code on error (#14)
* Change license to GPLv3
**0.11.0 (2017-05-07)**
* Fix error when running toot from crontab (#11)
* Minor tweaks
**0.10.0 (2017-04-26)**
* Add commands: `block`, `unblock`, `mute`, `unmute`
* Internal improvements
**0.9.1 (2017-04-24)**
* Fix conflict with curses package name
**0.9.0 (2017-04-21)**
* Add `whois` command
* Add experimental `curses` app for viewing the timeline
**0.8.0 (2017-04-19)**
* **BREAKING:** Renamed command `2fa` to `login_2fa`
* It is now possible to pipe text into `toot post`
**0.7.0 (2017-04-18)**
* **WARNING:** Due to changes in configuration format, after upgrading to this
version, you will be required to log in to your Mastodon instance again.
* Experimental 2FA support (#3)
* Do not create a new application for each login
**0.6.0 (2017-04-17)**
* Add `whoami` command
* Migrate from `optparse` to `argparse`
**0.5.0 (2017-04-16)**
* Add `search`, `follow` and `unfollow` commands
* Migrate from `optparse` to `argparse`
**0.4.0 (2017-04-15)**
* Add `upload` command to post media
* Add `--visibility` and `--media` options to `post` command
**0.3.0 (2017-04-13)**
* Add: view timeline
* Require an explicit login
**0.2.1 (2017-04-13)**
* Fix invalid requirements in setup.py
**0.2.0 (2017-04-12)**
* Bugfixes
**0.1.0 (2017-04-12)**
* Initial release

Wyświetl plik

@ -1,38 +0,0 @@
from datetime import datetime
# -- Project information -----------------------------------------------------
project = 'toot'
year = datetime.now().year
copyright = '{}, Ivan Habunek'.format(year)
author = 'Ivan Habunek'
# -- General configuration ---------------------------------------------------
extensions = []
templates_path = ['_templates']
source_suffix = '.rst'
master_doc = 'index'
exclude_patterns = ['_build']
pygments_style = 'sphinx'
# -- Options for HTML output -------------------------------------------------
html_theme = 'alabaster'
html_theme_options = {
"description": "Mastodon CLI client",
"github_user": "ihabunek",
"github_repo": "toot",
"fixed_sidebar": True,
"travis_button": True,
"logo": 'trumpet.png',
}
html_static_path = ['_static']
html_sidebars = {
"**": [
"about.html",
"navigation.html",
"relations.html",
"searchbox.html",
]
}

Wyświetl plik

@ -0,0 +1,148 @@
Toot contribution guide
=======================
Firstly, thank you for contributing to toot!
Relevant links which will be referenced below:
* [toot documentation](https://toot.bezdomni.net/)
* [toot-discuss mailing list](https://lists.sr.ht/~ihabunek/toot-discuss)
used for discussion as well as accepting patches
* [toot project on github](https://github.com/ihabunek/toot)
here you can report issues and submit pull requests
* #toot IRC channel on [libera.chat](https://libera.chat)
## Code of conduct
Please be kind and patient. Toot is maintained by one human with a full time
job.
## I have a question
First, check if your question is addressed in the documentation or the mailing
list. If not, feel free to send an email to the mailing list. You may want to
subscribe to the mailing list to receive replies.
Alternatively, you can ask your question on the IRC channel and ping me
(ihabunek). You may have to wait for a response, please be patient.
Please don't open Github issues for questions.
## I want to contribute
### Reporting a bug
First check you're using the
[latest version](https://github.com/ihabunek/toot/releases/) of toot and verify
the bug is present in this version.
Search [Github issues](https://github.com/ihabunek/toot/issues) to check the bug
hasn't already been reported.
To report a bug open an
[issue on Github](https://github.com/ihabunek/toot/issues) or send an
email to the [mailing list](https://lists.sr.ht/~ihabunek/toot-discuss).
* Run `toot env` and include its contents in the bug report.
* Explain the behavior you would expect and the actual behavior.
* Please provide as much context as possible and describe the reproduction steps
that someone else can follow to recreate the issue on their own.
### Suggesting enhancements
This includes suggesting new features or changes to existing ones.
Search Github issues to check the enhancement has not already been requested. If
it hasn't, [open a new issue](https://github.com/ihabunek/toot/issues).
Your request will be reviewed to see if it's a good fit for toot. Implementing
requested features depends on the available time and energy of the maintainer
and other contributors.
### Contributing code
When contributing to toot, please only submit code that you have authored or
code whose license allows it to be included in toot. You agree that the code
you submit will be published under the [toot license](LICENSE).
#### Setting up a dev environment
Check out toot (or a fork) and install it into a virtual environment.
```bash
git clone git@github.com:ihabunek/toot.git
cd toot
python3 -m venv _env
# On Linux/Mac
source _env/bin/activate
# On Windows
_env\bin\activate.bat
pip install --editable ".[dev,test]"
```
While the virtual env is active, running `toot` will execute the one you checked
out. This allows you to make changes and test them.
#### Crafting good commits
Please put some effort into breaking your contribution up into a series of well
formed commits. If you're unsure what this means, there is a good guide
available at [https://cbea.ms/git-commit/](https://cbea.ms/git-commit/).
Rules for commits:
* each commit should ideally contain only one change
* don't bundle multiple unrelated changes into a single commit
* write descriptive and well formatted commit messages
Rules for commit messages:
* separate subject from body with a blank line
* limit the subject line to 50 characters
* capitalize the subject line
* do not end the subject line with a period
* use the imperative mood in the subject line
* wrap the body at 72 characters
* use the body to explain what and why vs. how
For a more detailed explanation with examples see the guide at
[https://cbea.ms/git-commit/](https://cbea.ms/git-commit/)
If you use vim to write your commit messages, it will already enforce some of
these rules for you.
#### Run tests before submitting
You can run code and style tests by running:
```
make test
```
This runs three tools:
* `pytest` runs the test suite
* `flake8` checks code formatting
* `vermin` checks that minimum python version
Please ensure all three commands succeed before submitting your patches.
#### Submitting patches
To submit your code either open
[a pull request](https://github.com/ihabunek/toot/pulls) on Github, or send
patch(es) to [the mailing list](https://lists.sr.ht/~ihabunek/toot-discuss).
If sending to the mailing list, patches should be sent using `git send-email`.
If you're unsure how to do this, there is a good guide at
[https://git-send-email.io/](https://git-send-email.io/).
---
Parts of this guide were taken from the following sources:
* [https://contributing.md/](https://contributing.md/)
* [https://cbea.ms/git-commit/](https://cbea.ms/git-commit/)

Wyświetl plik

@ -0,0 +1,38 @@
Documentation
=============
Documentation is generated using [mdBook](https://rust-lang.github.io/mdBook/).
Documentation is written in markdown and located in the `docs` directory.
Additional plugins:
- [mdbook-toc](https://github.com/badboy/mdbook-toc)
Install prerequisites
---------------------
You'll need a moderately recent version of Rust (1.60) at the time of writing.
Check out [mdbook installation docs](https://rust-lang.github.io/mdBook/guide/installation.html)
for details.
Install by building from source:
```
cargo install mdbook mdbook-toc
```
Generate
--------
HTML documentation is generated from sources by running:
```
mdbook build
```
To run a local server which will rebuild on change:
```
mdbook serve
```

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

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 26 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 26 KiB

Wyświetl plik

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 16 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 16 KiB

Wyświetl plik

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 108 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 108 KiB

Wyświetl plik

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 192 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 192 KiB

Wyświetl plik

Przed

Szerokość:  |  Wysokość:  |  Rozmiar: 209 KiB

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 209 KiB

Wyświetl plik

@ -1,72 +0,0 @@
toot - Mastodon CLI client
==========================
.. image:: _static/trumpet.png
Toot is a CLI and TUI tool for interacting with Mastodon instances from the command line.
.. image:: https://img.shields.io/travis/ihabunek/toot.svg?maxAge=3600&style=flat-square
:target: https://travis-ci.org/ihabunek/toot
.. image:: https://img.shields.io/badge/author-%40ihabunek-blue.svg?maxAge=3600&style=flat-square
:target: https://mastodon.social/@ihabunek
.. image:: https://img.shields.io/github/license/ihabunek/toot.svg?maxAge=3600&style=flat-square
:target: https://opensource.org/licenses/GPL-3.0
.. image:: https://img.shields.io/pypi/v/toot.svg?maxAge=3600&style=flat-square
:target: https://pypi.python.org/pypi/toot
Resources
---------
* Homepage: https://github.com/ihabunek/toot
* Issues: https://github.com/ihabunek/toot/issues
* Documentation: https://toot.readthedocs.io/en/latest/
* Mailing list for discussion, support and patches:
https://lists.sr.ht/~ihabunek/toot-discuss
* Informal discussion: #toot IRC channel on `libera.chat <https://libera.chat/>`_
Features
--------
* Posting, replying, deleting, favouriting, reblogging & pinning statuses
* Support for media uploads, spoiler text, sensitive content
* Search by account or hash tag
* Following, muting and blocking accounts
* Simple switching between multiple Mastodon accounts
Contents
--------
.. toctree::
:maxdepth: 2
install
usage
advanced
release
Curses UI
---------
toot includes a curses-based terminal user interface (TUI). Run it with ``toot tui``.
.. image :: _static/tui_list.png
.. image :: _static/tui_poll.png
.. image :: _static/tui_compose.png
Development
-----------
The project source code and issue tracker are available on GitHub:
https://github.com/ihabunek/toot
Please report any issues there. Pull requests are welcome.
License
-------
Copyright Ivan Habunek <ivan@habunek.com> and contributors.
Licensed under `GPLv3 <http://www.gnu.org/licenses/gpl-3.0.html>`_.

Wyświetl plik

@ -1,123 +0,0 @@
============
Installation
============
toot is packaged for various platforms.
.. contents::
:local:
:backlinks: none
Overview
--------
Packaging overview provided by `repology.org <https://repology.org/project/toot/versions>`_.
.. image :: https://repology.org/badge/vertical-allrepos/toot.svg
:alt: Packaging status
:target: https://repology.org/project/toot/versions
Debian & Ubuntu
---------------
Since Debian 10 (buster) and Ubuntu 19.04 (disco), toot is available in the
official package repository.
.. code-block:: bash
sudo apt install toot
Debian package is maintained by `Jonathan Carter <https://mastodon.xyz/@highvoltage>`_.
Arch Linux
----------
Install from `AUR <https://aur.archlinux.org/packages/toot/>`_.
.. code-block:: bash
yay -S toot
Fedora
-------------
Toot is available from the Fedora package repository.
.. code-block:: bash
sudo dnf install toot
FreeBSD ports
-------------
Install the package:
.. code-block:: bash
pkg install py38-toot
Build and install from sources:
.. code-block:: bash
cd /usr/ports/net-im/toot
make install
FreeBSD port is maintained by `Mateusz Piotrowski <https://mastodon.social/@mpts>`_
Nixpkgs
-------
This works on NixOS or systems with the Nix package manager installed.
.. code-block:: bash
nix-env -iA nixos.toot
OpenBSD ports
-------------
Install the package:
.. code-block:: bash
pkg_add toot
Build and install from sources:
.. code-block:: bash
cd /usr/ports/net/toot
make install
OpenBSD port is maintained by `Klemens Nanni <mailto:kl3@posteo.org>`_
Python Package Index
--------------------
Install from PyPI using pip, preferably into a virtual environment.
.. code-block:: bash
pip install --user toot
Homebrew
--------------------
This works on Mac OSX with `homebrew <https://brew.sh/>`_ installed.
Tested with on Catalina, Mojave, and High Sierra.
.. code-block:: bash
brew update
brew install toot
Source
------
Finally, you can get the latest source distribution, wheel or debian package
`from GitHub <https://github.com/ihabunek/toot/releases/latest/>`_.

Wyświetl plik

@ -0,0 +1,22 @@
Installation
============
toot is packaged for various platforms. If possible use your OS's package manager to install toot.
[![Packaging status](https://repology.org/badge/vertical-allrepos/toot.svg)](https://repology.org/project/toot/versions)
## Python Package Index
Install from PyPI using pip, preferably into a virtual environment.
pip install toot
## Homebrew
For Mac OSX users, toot is available [in homebrew](https://formulae.brew.sh/formula/toot#default).
brew install toot
## From source
You can get the latest source distribution [from Github](https://github.com/ihabunek/toot/releases/latest/).

Wyświetl plik

@ -0,0 +1,46 @@
toot - Mastodon CLI client
==========================
![Toot trumpet logo](./trumpet.png)
Toot is a CLI and TUI tool for interacting with Mastodon (and other compatible) instances from the command line.
[![](https://img.shields.io/badge/author-%40ihabunek-blue.svg?maxAge=3600&style=flat-square)](https://mastodon.social/@ihabunek)
[![](https://img.shields.io/github/license/ihabunek/toot.svg?maxAge=3600&style=flat-square)](https://opensource.org/licenses/GPL-3.0)
[![](https://img.shields.io/pypi/v/toot.svg?maxAge=3600&style=flat-square)](https://pypi.python.org/pypi/toot)
Resources
---------
* [Documentation](https://toot.bezdomni.net/)
* [Source code on GitHub](https://github.com/ihabunek/toot)
* [Issues on GitHub](https://github.com/ihabunek/toot/issues)
* [Mailing list on Sourcehut](https://lists.sr.ht/~ihabunek/toot-discuss) for discussion, support and patches
* Informal discussion on the #toot IRC channel on [libera.chat](https://libera.chat/)
Command line client
-------------------
* Posting, replying, deleting, favouriting, reblogging & pinning statuses
* Support for media uploads, spoiler text, sensitive content
* Search by account or hash tag
* Following, muting and blocking accounts
* Simple switching between multiple Mastodon accounts
Terminal User Interface
-----------------------
toot includes a terminal user interface. Run it with `toot tui`.
![](images/tui_list.png)
![](images/tui_poll.png)
![](images/tui_compose.png)
License
-------
Copyright Ivan Habunek <ivan@habunek.com> and contributors.
Licensed under the [GPLv3](http://www.gnu.org/licenses/gpl-3.0.html) license.

675
docs/license.md 100644
Wyświetl plik

@ -0,0 +1,675 @@
### GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc.
<https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies of this
license document, but changing it is not allowed.
### Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom
to share and change all versions of a program--to make sure it remains
free software for all its users. We, the Free Software Foundation, use
the GNU General Public License for most of our software; it applies
also to any other work released this way by its authors. You can apply
it to your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you
have certain responsibilities if you distribute copies of the
software, or if you modify it: responsibilities to respect the freedom
of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the
manufacturer can do so. This is fundamentally incompatible with the
aim of protecting users' freedom to change the software. The
systematic pattern of such abuse occurs in the area of products for
individuals to use, which is precisely where it is most unacceptable.
Therefore, we have designed this version of the GPL to prohibit the
practice for those products. If such problems arise substantially in
other domains, we stand ready to extend this provision to those
domains in future versions of the GPL, as needed to protect the
freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish
to avoid the special danger that patents applied to a free program
could make it effectively proprietary. To prevent this, the GPL
assures that patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
### TERMS AND CONDITIONS
#### 0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds
of works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of
an exact copy. The resulting work is called a "modified version" of
the earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user
through a computer network, with no transfer of a copy, is not
conveying.
An interactive user interface displays "Appropriate Legal Notices" to
the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
#### 1. Source Code.
The "source code" for a work means the preferred form of the work for
making modifications to it. "Object code" means any non-source form of
a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users can
regenerate automatically from other parts of the Corresponding Source.
The Corresponding Source for a work in source code form is that same
work.
#### 2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not convey,
without conditions so long as your license otherwise remains in force.
You may convey covered works to others for the sole purpose of having
them make modifications exclusively for you, or provide you with
facilities for running those works, provided that you comply with the
terms of this License in conveying all material for which you do not
control copyright. Those thus making or running the covered works for
you must do so exclusively on your behalf, under your direction and
control, on terms that prohibit them from making any copies of your
copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under the
conditions stated below. Sublicensing is not allowed; section 10 makes
it unnecessary.
#### 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such
circumvention is effected by exercising rights under this License with
respect to the covered work, and you disclaim any intention to limit
operation or modification of the work as a means of enforcing, against
the work's users, your or third parties' legal rights to forbid
circumvention of technological measures.
#### 4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
#### 5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these
conditions:
- a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
- b) The work must carry prominent notices stating that it is
released under this License and any conditions added under
section 7. This requirement modifies the requirement in section 4
to "keep intact all notices".
- c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
- d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
#### 6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms of
sections 4 and 5, provided that you also convey the machine-readable
Corresponding Source under the terms of this License, in one of these
ways:
- a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
- b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the Corresponding
Source from a network server at no charge.
- c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
- d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
- e) Convey the object code using peer-to-peer transmission,
provided you inform other peers where the object code and
Corresponding Source of the work are being offered to the general
public at no charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal,
family, or household purposes, or (2) anything designed or sold for
incorporation into a dwelling. In determining whether a product is a
consumer product, doubtful cases shall be resolved in favor of
coverage. For a particular product received by a particular user,
"normally used" refers to a typical or common use of that class of
product, regardless of the status of the particular user or of the way
in which the particular user actually uses, or expects or is expected
to use, the product. A product is a consumer product regardless of
whether the product has substantial commercial, industrial or
non-consumer uses, unless such uses represent the only significant
mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to
install and execute modified versions of a covered work in that User
Product from a modified version of its Corresponding Source. The
information must suffice to ensure that the continued functioning of
the modified object code is in no case prevented or interfered with
solely because modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or
updates for a work that has been modified or installed by the
recipient, or for the User Product in which it has been modified or
installed. Access to a network may be denied when the modification
itself materially and adversely affects the operation of the network
or violates the rules and protocols for communication across the
network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
#### 7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders
of that material) supplement the terms of this License with terms:
- a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
- b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
- c) Prohibiting misrepresentation of the origin of that material,
or requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
- d) Limiting the use for publicity purposes of names of licensors
or authors of the material; or
- e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
- f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions
of it) with contractual assumptions of liability to the recipient,
for any liability that these contractual assumptions directly
impose on those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions; the
above requirements apply either way.
#### 8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your license
from a particular copyright holder is reinstated (a) provisionally,
unless and until the copyright holder explicitly and finally
terminates your license, and (b) permanently, if the copyright holder
fails to notify you of the violation by some reasonable means prior to
60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
#### 9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or run
a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
#### 10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
#### 11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims owned
or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within the
scope of its coverage, prohibits the exercise of, or is conditioned on
the non-exercise of one or more of the rights that are specifically
granted under this License. You may not convey a covered work if you
are a party to an arrangement with a third party that is in the
business of distributing software, under which you make payment to the
third party based on the extent of your activity of conveying the
work, and under which the third party grants, to any of the parties
who would receive the covered work from you, a discriminatory patent
license (a) in connection with copies of the covered work conveyed by
you (or copies made from those copies), or (b) primarily for and in
connection with specific products or compilations that contain the
covered work, unless you entered into that arrangement, or that patent
license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
#### 12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under
this License and any other pertinent obligations, then as a
consequence you may not convey it at all. For example, if you agree to
terms that obligate you to collect a royalty for further conveying
from those to whom you convey the Program, the only way you could
satisfy both those terms and this License would be to refrain entirely
from conveying the Program.
#### 13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
#### 14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions
of the GNU General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in
detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies that a certain numbered version of the GNU General Public
License "or any later version" applies to it, you have the option of
following the terms and conditions either of that numbered version or
of any later version published by the Free Software Foundation. If the
Program does not specify a version number of the GNU General Public
License, you may choose any version ever published by the Free
Software Foundation.
If the Program specifies that a proxy can decide which future versions
of the GNU General Public License can be used, that proxy's public
statement of acceptance of a version permanently authorizes you to
choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
#### 15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT
WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND
PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE
DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR
CORRECTION.
#### 16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR
CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES
ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT
NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR
LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM
TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER
PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
#### 17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
### How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these
terms.
To do so, attach the following notices to the program. It is safest to
attach them to the start of each source file to most effectively state
the exclusion of warranty; and each file should have at least the
"copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper
mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands \`show w' and \`show c' should show the
appropriate parts of the General Public License. Of course, your
program's commands might be different; for a GUI interface, you would
use an "about box".
You should also get your employer (if you work as a programmer) or
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. For more information on this, and how to apply and follow
the GNU GPL, see <https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your
program into proprietary programs. If your program is a subroutine
library, you may consider it more useful to permit linking proprietary
applications with the library. If this is what you want to do, use the
GNU Lesser General Public License instead of this License. But first,
please read <https://www.gnu.org/licenses/why-not-lgpl.html>.

Wyświetl plik

@ -1,36 +0,0 @@
@ECHO OFF
pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=.
set BUILDDIR=_build
set SPHINXPROJ=toot
if "%1" == "" goto help
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.http://sphinx-doc.org/
exit /b 1
)
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
goto end
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
:end
popd

43
docs/release.md 100644
Wyświetl plik

@ -0,0 +1,43 @@
Release procedure
=================
This document is a checklist for creating a toot release.
Currently the process is pretty manual and would benefit from automatization.
Bump & tag version
------------------
* Update the version number in `setup.py`
* Update the version number in `toot/__init__.py`
* Update `changelog.yaml` with the release notes & date
* Run `make changelog` to generate a human readable changelog
* Commit the changes
* Run `./scripts/tag_version <version>` to tag a release in git
* Run `git push --follow-tags` to upload changes and tag to GitHub
Publishing to PyPI
------------------
* `make dist` to create source and wheel distributions
* `make publish` to push them to PyPI
GitHub release
--------------
* [Create a release](https://github.com/ihabunek/toot/releases/) for the newly
pushed tag, paste changelog since last tag in the description
* Upload the assets generated in previous two steps to the release:
* source dist (.zip and .tar.gz)
* wheel distribution (.whl)
TODO: this can be automated: https://developer.github.com/v3/repos/releases/
Update documentation
--------------------
To regenerate HTML docs and deploy to toot.bezdomni.net:
```
make docs-deploy
```

Wyświetl plik

@ -1,35 +0,0 @@
=================
Release procedure
=================
This document is a checklist for creating a toot release.
Currently the process is pretty manual and would benefit from automatization.
Bump & tag version
------------------
* Update the version number in ``setup.py``
* Update the version number in ``toot/__init__.py``
* Update ``changelog.yaml`` with the release notes & date
* Run ``make changelog`` to generate a human readable changelog
* Commit the changes
* Run ``./scripts/tag_version <version>`` to tag a release in git
* Run ``git push --follow-tags`` to upload changes and tag to GitHub
Publishing to PyPI
------------------
* ``make dist`` to create source and wheel distributions
* ``make publish`` to push them to PyPI
GitHub release
--------------
* `Create a release <https://github.com/ihabunek/toot/releases/>`_ for the newly
pushed tag, paste changelog since last tag in the description
* Upload the assets generated in previous two steps to the release:
* source dist (.zip and .tar.gz)
* wheel distribution (.whl)
TODO: this can be automated: https://developer.github.com/v3/repos/releases/

126
docs/settings.md 100644
Wyświetl plik

@ -0,0 +1,126 @@
# Settings
Toot can be configured via a [TOML](https://toml.io/en/) settings file.
> Introduced in toot 0.37.0
> **Warning:** Settings are experimental and things may change without warning.
Toot will look for the settings file at:
* `~/.config/toot/settings.toml` (Linux & co.)
* `%APPDATA%\toot\settings.toml` (Windows)
Toot will respect the `XDG_CONFIG_HOME` environment variable if it's set and
look for the settings file in `$XDG_CONFIG_HOME/toot` instead of
`~/.config/toot`.
## Common options
The `[common]` section includes common options which are applied to all commands.
```toml
[common]
# Whether to use ANSI color in output
color = true
# Enable debug logging, shows HTTP requests
debug = true
# Redirect debug log to the given file
debug_file = "/tmp/toot.log"
# Log request and response bodies in the debug log
verbose = false
# Do not write to output
quiet = false
```
## Overriding command defaults
Defaults for command arguments can be override by specifying a `[commands.<name>]` section.
For example, to override `toot post`.
```toml
[commands.post]
editor = "vim"
sensitive = true
visibility = "unlisted"
scheduled_in = "30 minutes"
```
## TUI view images
> Introduced in toot 0.39.0
You can view images in a toot using an external program by setting the
`tui.media_viewer` option to your desired image viewer. When a toot is focused,
pressing `m` will launch the specified executable giving one or more URLs as
arguments. This works well with image viewers like `feh` which accept URLs as
arguments.
```toml
[tui]
media_viewer = "feh"
```
## TUI color palette
TUI uses Urwid which provides several color modes. See
[Urwid documentation](https://urwid.org/manual/displayattributes.html)
for more details.
By default, TUI operates in 16-color mode which can be changed by setting the
`color` setting in the `[tui]` section to one of the following values:
* `1` (monochrome)
* `16` (default)
* `88`
* `256`
* `16777216` (24 bit)
TUI defines a list of colors which can be customized, currently they can be seen
[in the source code](https://github.com/ihabunek/toot/blob/master/toot/tui/constants.py). They can be overridden in the `[tui.palette]` section.
Each color is defined as a list of upto 5 values:
* foreground color (16 color mode)
* background color (16 color mode)
* monochrome color (monochrome mode)
* foreground color (high-color mode)
* background color (high-color mode)
Any colors which are not used by your desired color mode can be skipped or set
to an empty string.
For example, to change the button colors in 16 color mode:
```toml
[tui.palette]
button = ["dark red,bold", ""]
button_focused = ["light gray", "green"]
```
In monochrome mode:
```toml
[tui]
colors = 1
[tui.palette]
button = ["", "", "bold"]
button_focused = ["", "", "italics"]
```
In 256 color mode:
```toml
[tui]
colors = 256
[tui.palette]
button = ["", "", "", "#aaa", "#bbb"]
button_focused = ["", "", "", "#aaa", "#bbb"]
```

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

BIN
docs/trumpet.png 100644

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 16 KiB

47
docs/tui.md 100644
Wyświetl plik

@ -0,0 +1,47 @@
TUI
===
toot includes a
[text-based user interface](https://en.wikipedia.org/wiki/Text-based_user_interface).
Start it by running `toot tui`.
## Demo
[![asciicast](https://asciinema.org/a/563459.svg)](https://asciinema.org/a/563459)
## Keyboard shortcuts
Pressing `H` will bring up the help screen where all keyboard shortcuts are
listed.
**Navigation**
* `Arrow keys` or `H/J/K/L` to move around and scroll content
* `PageUp` and `PageDown` to scroll content
* `Enter` or `Space` to activate buttons and menu options
* `Esc` or `Q` to go back, close overlays and menus
**General**
* `Q` - quit toot
* `G` - go to - switch timelines
* `P` - save/unsave (pin) current timeline
* `,` - refresh current timeline
* `H` - show this help
**Status**
These commands are applied to the currently focused status.
* `B` - Boost/unboost status
* `C` - Compose new status
* `F` - Favourite/unfavourite status
* `K` - Bookmark/unbookmark status
* `N` - Translate status if possible (toggle)
* `R` - Reply to current status
* `S` - Show text marked as sensitive
* `T` - Show status thread (replies)
* `L` - Show the status links
* `U` - Show the status data in JSON as received from the server
* `V` - Open status in default browser
* `Z` - Open status in scrollable popup window

176
docs/usage.md 100644
Wyświetl plik

@ -0,0 +1,176 @@
Usage
=====
Running `toot` displays a list of available commands.
Running `toot <command> -h` shows the documentation for the given command.
Below is an overview of some common scenarios.
<!-- toc -->
Authentication
--------------
Before tooting, you need to log into a Mastodon instance.
toot login
You will be redirected to your Mastodon instance to log in and authorize toot to
access your account, and will be given an **authorization code** in return
which you need to enter to log in.
The application and user access tokens will be saved in the configuration file
located at `~/.config/toot/config.json`.
### Using multiple accounts
It's possible to be logged into multiple accounts at the same time. Just
repeat the login process for another instance. You can see all logged in
accounts by running `toot auth`. The currently active account will have an
**ACTIVE** flag next to it.
To switch accounts, use `toot activate`. Alternatively, most commands accept a
`--using` option which can be used to specify the account you wish to use just
that one time.
Finally you can logout from an account by using `toot logout`. This will
remove the stored access tokens for that account.
Post a status
-------------
The simplest action is posting a status.
```sh
toot post "hello there"
```
You can also pipe in the status text:
```sh
echo "Text to post" | toot post
cat post.txt | toot post
toot post < post.txt
```
If no status text is given, you will be prompted to enter some:
```sh
$ toot post
Write or paste your toot. Press Ctrl-D to post it.
```
Finally, you can launch your favourite editor:
```sh
toot post --editor vim
```
Define your editor preference in the `EDITOR` environment variable, then you
don't need to specify it explicitly:
```sh
export EDITOR=vim
toot post --editor
```
### Attachments
You can attach media to your status. Mastodon supports images, video and audio
files. For details on supported formats see
[Mastodon docs on attachments](https://docs.joinmastodon.org/user/posting/#attachments).
It is encouraged to add a plain-text description to the attached media for
accessibility purposes by adding a `--description` option.
To attach an image:
```sh
toot post "hello media" --media path/to/image.png --description "Cool image"
```
You can attach upto 4 attachments by giving multiple `--media` and
`--description` options:
```sh
toot post "hello media" \
--media path/to/image1.png --description "First image" \
--media path/to/image2.png --description "Second image" \
--media path/to/image3.png --description "Third image" \
--media path/to/image4.png --description "Fourth image"
```
The order of options is not relevant, except that the first given media will be
matched to the first given description and so on.
If the media is sensitive, mark it as such and people will need to click to show
it. This affects all attachments.
```sh
toot post "naughty pics ahoy" --media nsfw.png --sensitive
```
View timeline
-------------
View what's on your home timeline:
```sh
toot timeline
```
Timeline takes various options:
```sh
toot timeline --public # public timeline
toot timeline --public --local # public timeline, only this instance
toot timeline --tag photo # posts tagged with #photo
toot timeline --count 5 # fetch 5 toots (max 20)
toot timeline --once # don't prompt to fetch more toots
```
Add `--help` to see all the options.
Status actions
--------------
The timeline lists the status ID at the bottom of each toot. Using that status
you can do various actions to it, e.g.:
```sh
toot favourite 123456
toot reblog 123456
```
If it's your own status you can also delete pin or delete it:
```sh
toot pin 123456
toot delete 123456
```
Account actions
---------------
Find a user by their name or account name:
```sh
toot search "name surname"
toot search @someone
toot search someone@someplace.social
```
Once found, follow them:
```sh
toot follow someone@someplace.social
```
If you get bored of them:
```sh
toot mute someone@someplace.social
toot block someone@someplace.social
toot unfollow someone@someplace.social
```

Wyświetl plik

@ -1,248 +0,0 @@
=====
Usage
=====
Running ``toot`` displays a list of available commands.
Running ``toot <command> -h`` shows the documentation for the given command.
.. code-block:: none
$ toot
toot - a Mastodon CLI client
v0.27.0
Authentication:
toot login Log into a mastodon instance using your browser (recommended)
toot login_cli Log in from the console, does NOT support two factor authentication
toot activate Switch between logged in accounts.
toot logout Log out, delete stored access keys
toot auth Show logged in accounts and instances
TUI:
toot tui Launches the toot terminal user interface
Read:
toot whoami Display logged in user details
toot whois Display account details
toot notifications Notifications for logged in user
toot instance Display instance details
toot search Search for users or hashtags
toot thread Show toot thread items
toot timeline Show recent items in a timeline (home by default)
Post:
toot post Post a status text to your timeline
toot upload Upload an image or video file
Status:
toot delete Delete a status
toot favourite Favourite a status
toot unfavourite Unfavourite a status
toot reblog Reblog a status
toot unreblog Unreblog a status
toot reblogged_by Show accounts that reblogged the status
toot pin Pin a status
toot unpin Unpin a status
Accounts:
toot follow Follow an account
toot unfollow Unfollow an account
toot mute Mute an account
toot unmute Unmute an account
toot block Block an account
toot unblock Unblock an account
To get help for each command run:
toot <command> --help
https://github.com/ihabunek/toot
Authentication
--------------
Before tooting, you need to log into a Mastodon instance.
.. code-block:: sh
toot login
You will be redirected to your Mastodon instance to log in and authorize toot to
access your account, and will be given an **authorization code** in return which
you need to enter to log in.
The application and user access tokens will be saved in the configuration file
located at ``~/.config/toot/config.json``.
Using multiple accounts
~~~~~~~~~~~~~~~~~~~~~~~
It's possible to be logged into **multiple accounts** at the same time. Just
repeat the login process for another instance. You can see all logged in
accounts by running ``toot auth``. The currently active account will have an
**ACTIVE** flag next to it.
To switch accounts, use ``toot activate``. Alternatively, most commands accept a
``--using`` option which can be used to specify the account you wish to use just
that one time.
Finally you can logout from an account by using ``toot logout``. This will
remove the stored access tokens for that account.
Post a status
-------------
The simplest action is posting a status.
.. code-block:: bash
toot post "hello there"
You can also pipe in the status text:
.. code-block:: bash
echo "Text to post" | toot post
cat post.txt | toot post
toot post < post.txt
If no status text is given, you will be prompted to enter some:
.. code-block:: bash
$ toot post
Write or paste your toot. Press Ctrl-D to post it.
Finally, you can launch your favourite editor:
.. code-block:: bash
toot post --editor vim
Define your editor preference in the ``EDITOR`` environment variable, then you
don't need to specify it explicitly:
.. code-block:: bash
export EDITOR=vim
toot post --editor
Attachments
~~~~~~~~~~~
You can attach media to your status. Mastodon supports images, video and audio
files. For details on supported formats see `Mastodon docs on attachments
<https://docs.joinmastodon.org/user/posting/#attachments>`_.
It is encouraged to add a plain-text description to the attached media for
accessibility purposes by adding a ``--description`` option.
To attach an image:
.. code-block:: bash
toot post "hello media" --media path/to/image.png --description "Cool image"
You can attach upto 4 attachments by giving multiple ``--media`` and
``--description`` options:
.. code-block:: bash
toot post "hello media" \
--media path/to/image1.png --description "First image" \
--media path/to/image2.png --description "Second image" \
--media path/to/image3.png --description "Third image" \
--media path/to/image4.png --description "Fourth image"
The order of options is not relevant, except that the first given media will be
matched to the first given description and so on.
If the media is sensitive, mark it as such and people will need to click to show
it. This affects all attachments.
.. code-block:: bash
toot post "naughty pics ahoy" --media nsfw.png --sensitive
View timeline
-------------
View what's on your home timeline:
.. code-block:: bash
toot timeline
Timeline takes various options:
.. code-block:: bash
toot timeline --public # public timeline
toot timeline --public --local # public timeline, only this instance
toot timeline --tag photo # posts tagged with #photo
toot timeline --count 5 # fetch 5 toots (max 20)
toot timeline --once # don't prompt to fetch more toots
Status actions
--------------
The timeline lists the status ID at the bottom of each toot. Using that status
you can do various actions to it, e.g.:
.. code-block:: bash
toot favourite 123456
toot reblog 123456
If it's your own status you can also delete pin or delete it:
.. code-block:: bash
toot pin 123456
toot delete 123456
Account actions
---------------
Find a user by their name or account name:
.. code-block:: bash
toot search "name surname"
toot search @someone
toot search someone@someplace.social
Once found, follow them:
.. code-block:: bash
toot follow someone@someplace.social
If you get bored of them:
.. code-block:: bash
toot mute someone@someplace.social
toot block someone@someplace.social
toot unfollow someone@someplace.social
Using the Curses UI
-------------------
toot has a curses-based terminal user interface. The command to start it is ``toot tui``.
To navigate the UI use these commands:
* ``k`` or ``up arrow`` to move up the list of tweets
* ``j`` or ``down arrow`` to move down the list of tweets
* ``h`` to show a help screen
* ``t`` to view status thread
* ``v`` to view the current toot in a browser
* ``b`` to boost or unboost a status
* ``f`` to favourite or unfavourite a status
* ``q`` to quit the curses interface and return to the command line
* ``s`` to show sensitive content. (This is per-toot, and there will be a read bar in the toot to indicate that it is there.)
*Note that the curses UI is not available on Windows.*

2
pytest.ini 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,4 +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

@ -43,6 +43,7 @@ if dist_version != version:
sys.exit(1)
release_date = changelog_item["date"]
description = changelog_item.get("description")
changes = changelog_item["changes"]
if not isinstance(release_date, date):
@ -50,6 +51,11 @@ if not isinstance(release_date, date):
sys.exit(1)
commit_message = f"toot {version}\n\n"
if description:
lines = textwrap.wrap(description.strip(), 72)
commit_message += "\n".join(lines) + "\n\n"
for c in changes:
lines = textwrap.wrap(c, 70)
initial = True

Wyświetl plik

@ -12,14 +12,14 @@ and blocking accounts and other actions.
setup(
name='toot',
version='0.33.1',
version='0.41.1',
description='Mastodon CLI client',
long_description=long_description.strip(),
author='Ivan Habunek',
author_email='ivan@habunek.com',
url='https://github.com/ihabunek/toot/',
project_urls={
'Documentation': 'https://toot.readthedocs.io/en/latest/',
'Documentation': 'https://toot.bezdomni.net/',
'Issue tracker': 'https://github.com/ihabunek/toot/issues/',
},
keywords='mastodon toot',
@ -31,17 +31,40 @@ setup(
'License :: OSI Approved :: GNU General Public License v3 (GPLv3)',
'Programming Language :: Python :: 3',
],
packages=['toot', 'toot.tui', 'toot.utils'],
python_requires=">=3.6",
packages=['toot', 'toot.cli', 'toot.tui', 'toot.tui.richtext', 'toot.utils'],
python_requires=">=3.7",
install_requires=[
"click~=8.1",
"requests>=2.13,<3.0",
"beautifulsoup4>=4.5.0,<5.0",
"wcwidth>=0.1.7",
"urwid>=2.0.0,<3.0",
"tomlkit>=0.10.0,<1.0"
],
extras_require={
# Required to display rich text in the TUI
"richtext": [
"urwidgets>=0.1,<0.2"
],
"dev": [
"coverage",
"pyyaml",
"twine",
"wheel",
],
"test": [
"flake8",
"psycopg2-binary",
"pytest",
"pytest-xdist[psutil]",
"setuptools",
"vermin",
"typing-extensions",
],
},
entry_points={
'console_scripts': [
'toot=toot.console:main',
'toot=toot.cli:cli',
],
}
)

Plik binarny nie jest wyświetlany.

Wyświetl plik

@ -0,0 +1,155 @@
"""
This module contains integration tests meant to run against a test Mastodon instance.
You can set up a test instance locally by following this guide:
https://docs.joinmastodon.org/dev/setup/
To enable integration tests, export the following environment variables to match
your test server and database:
```
export TOOT_TEST_BASE_URL="localhost:3000"
```
"""
import json
import os
import pytest
import re
import typing as t
import uuid
from click.testing import CliRunner, Result
from pathlib import Path
from toot import api, App, User
from toot.cli import Context, TootObj
def pytest_configure(config):
import toot.settings
toot.settings.DISABLE_SETTINGS = True
# Type alias for run commands
Run = t.Callable[..., Result]
# Mastodon database name, used to confirm user registration without having to click the link
TOOT_TEST_BASE_URL = os.getenv("TOOT_TEST_BASE_URL")
# Toot logo used for testing image upload
TRUMPET = str(Path(__file__).parent.parent.parent / "trumpet.png")
ASSETS_DIR = str(Path(__file__).parent.parent / "assets")
def create_app(base_url):
instance = api.get_instance(base_url).json()
response = api.create_app(base_url)
return App(instance["uri"], base_url, response["client_id"], response["client_secret"])
def register_account(app: App):
username = str(uuid.uuid4())[-10:]
email = f"{username}@example.com"
response = api.register_account(app, username, email, "password", "en")
return User(app.instance, username, response["access_token"])
# ------------------------------------------------------------------------------
# Fixtures
# ------------------------------------------------------------------------------
# Host name of a test instance to run integration tests against
# DO NOT USE PUBLIC INSTANCES!!!
@pytest.fixture(scope="session")
def base_url():
if not TOOT_TEST_BASE_URL:
pytest.skip("Skipping integration tests, TOOT_TEST_BASE_URL not set")
return TOOT_TEST_BASE_URL
@pytest.fixture(scope="session")
def app(base_url):
return create_app(base_url)
@pytest.fixture(scope="session")
def user(app):
return register_account(app)
@pytest.fixture(scope="session")
def friend(app):
return register_account(app)
@pytest.fixture(scope="session")
def user_id(app, user):
return api.find_account(app, user, user.username)["id"]
@pytest.fixture(scope="session")
def friend_id(app, user, friend):
return api.find_account(app, user, friend.username)["id"]
@pytest.fixture(scope="session", autouse=True)
def testing_env():
os.environ["TOOT_TESTING"] = "true"
@pytest.fixture(scope="session")
def runner():
return CliRunner(mix_stderr=False)
@pytest.fixture
def run(app, user, runner):
def _run(command, *params, input=None) -> Result:
obj = TootObj(test_ctx=Context(app, user))
return runner.invoke(command, params, obj=obj, input=input)
return _run
@pytest.fixture
def run_as(app, runner):
def _run_as(user, command, *params, input=None) -> Result:
obj = TootObj(test_ctx=Context(app, user))
return runner.invoke(command, params, obj=obj, input=input)
return _run_as
@pytest.fixture
def run_json(app, user, runner):
def _run_json(command, *params):
obj = TootObj(test_ctx=Context(app, user))
result = runner.invoke(command, params, obj=obj)
assert result.exit_code == 0
return json.loads(result.stdout)
return _run_json
@pytest.fixture
def run_anon(runner):
def _run(command, *params) -> Result:
obj = TootObj(test_ctx=Context(None, None))
return runner.invoke(command, params, obj=obj)
return _run
# ------------------------------------------------------------------------------
# Utils
# ------------------------------------------------------------------------------
def posted_status_id(out):
pattern = re.compile(r"Toot posted: http://([^/]+)/([^/]+)/(.+)")
match = re.search(pattern, out)
assert match
_, _, status_id = match.groups()
return status_id

Wyświetl plik

@ -0,0 +1,274 @@
import json
from toot import App, User, api, cli
from toot.entities import Account, Relationship, from_dict
def test_whoami(user: User, run):
result = run(cli.read.whoami)
assert result.exit_code == 0
# TODO: test other fields once updating account is supported
out = result.stdout.strip()
assert f"@{user.username}" in out
def test_whoami_json(user: User, run):
result = run(cli.read.whoami, "--json")
assert result.exit_code == 0
account = from_dict(Account, json.loads(result.stdout))
assert account.username == user.username
def test_whois(app: App, friend: User, run):
variants = [
friend.username,
f"@{friend.username}",
f"{friend.username}@{app.instance}",
f"@{friend.username}@{app.instance}",
]
for username in variants:
result = run(cli.read.whois, username)
assert result.exit_code == 0
assert f"@{friend.username}" in result.stdout
def test_following(app: App, user: User, friend: User, friend_id, run):
# Make sure we're not initially following friend
api.unfollow(app, user, friend_id)
result = run(cli.accounts.following, user.username)
assert result.exit_code == 0
assert result.stdout.strip() == ""
result = run(cli.accounts.follow, friend.username)
assert result.exit_code == 0
assert result.stdout.strip() == f"✓ You are now following {friend.username}"
result = run(cli.accounts.following, user.username)
assert result.exit_code == 0
assert friend.username in result.stdout.strip()
# If no account is given defaults to logged in user
result = run(cli.accounts.following)
assert result.exit_code == 0
assert friend.username in result.stdout.strip()
result = run(cli.accounts.unfollow, friend.username)
assert result.exit_code == 0
assert result.stdout.strip() == f"✓ You are no longer following {friend.username}"
result = run(cli.accounts.following, user.username)
assert result.exit_code == 0
assert result.stdout.strip() == ""
def test_following_case_insensitive(user: User, friend: User, run):
assert friend.username != friend.username.upper()
result = run(cli.accounts.follow, friend.username.upper())
assert result.exit_code == 0
out = result.stdout.strip()
assert out == f"✓ You are now following {friend.username.upper()}"
def test_following_not_found(run):
result = run(cli.accounts.follow, "bananaman")
assert result.exit_code == 1
assert result.stderr.strip() == "Error: Account not found"
result = run(cli.accounts.unfollow, "bananaman")
assert result.exit_code == 1
assert result.stderr.strip() == "Error: Account not found"
def test_following_json(app: App, user: User, friend: User, user_id, friend_id, run_json):
# Make sure we're not initially following friend
api.unfollow(app, user, friend_id)
result = run_json(cli.accounts.following, user.username, "--json")
assert result == []
result = run_json(cli.accounts.followers, friend.username, "--json")
assert result == []
result = run_json(cli.accounts.follow, friend.username, "--json")
relationship = from_dict(Relationship, result)
assert relationship.id == friend_id
assert relationship.following is True
[result] = run_json(cli.accounts.following, user.username, "--json")
relationship = from_dict(Relationship, result)
assert relationship.id == friend_id
# If no account is given defaults to logged in user
[result] = run_json(cli.accounts.following, user.username, "--json")
relationship = from_dict(Relationship, result)
assert relationship.id == friend_id
[result] = run_json(cli.accounts.followers, friend.username, "--json")
assert result["id"] == user_id
result = run_json(cli.accounts.unfollow, friend.username, "--json")
assert result["id"] == friend_id
assert result["following"] is False
result = run_json(cli.accounts.following, user.username, "--json")
assert result == []
result = run_json(cli.accounts.followers, friend.username, "--json")
assert result == []
def test_mute(app, user, friend, friend_id, run):
# Make sure we're not initially muting friend
api.unmute(app, user, friend_id)
result = run(cli.accounts.muted)
assert result.exit_code == 0
out = result.stdout.strip()
assert out == "No accounts muted"
result = run(cli.accounts.mute, friend.username)
assert result.exit_code == 0
out = result.stdout.strip()
assert out == f"✓ You have muted {friend.username}"
result = run(cli.accounts.muted)
assert result.exit_code == 0
out = result.stdout.strip()
assert friend.username in out
result = run(cli.accounts.unmute, friend.username)
assert result.exit_code == 0
out = result.stdout.strip()
assert out == f"{friend.username} is no longer muted"
result = run(cli.accounts.muted)
assert result.exit_code == 0
out = result.stdout.strip()
assert out == "No accounts muted"
def test_mute_case_insensitive(friend: User, run):
result = run(cli.accounts.mute, friend.username.upper())
assert result.exit_code == 0
out = result.stdout.strip()
assert out == f"✓ You have muted {friend.username.upper()}"
def test_mute_not_found(run):
result = run(cli.accounts.mute, "doesnotexistperson")
assert result.exit_code == 1
assert result.stderr.strip() == "Error: Account not found"
result = run(cli.accounts.unmute, "doesnotexistperson")
assert result.exit_code == 1
assert result.stderr.strip() == "Error: Account not found"
def test_mute_json(app: App, user: User, friend: User, run_json, friend_id):
# Make sure we're not initially muting friend
api.unmute(app, user, friend_id)
result = run_json(cli.accounts.muted, "--json")
assert result == []
result = run_json(cli.accounts.mute, friend.username, "--json")
relationship = from_dict(Relationship, result)
assert relationship.id == friend_id
assert relationship.muting is True
[result] = run_json(cli.accounts.muted, "--json")
account = from_dict(Account, result)
assert account.id == friend_id
result = run_json(cli.accounts.unmute, friend.username, "--json")
relationship = from_dict(Relationship, result)
assert relationship.id == friend_id
assert relationship.muting is False
result = run_json(cli.accounts.muted, "--json")
assert result == []
def test_block(app, user, friend, friend_id, run):
# Make sure we're not initially blocking friend
api.unblock(app, user, friend_id)
result = run(cli.accounts.blocked)
assert result.exit_code == 0
out = result.stdout.strip()
assert out == "No accounts blocked"
result = run(cli.accounts.block, friend.username)
assert result.exit_code == 0
out = result.stdout.strip()
assert out == f"✓ You are now blocking {friend.username}"
result = run(cli.accounts.blocked)
assert result.exit_code == 0
out = result.stdout.strip()
assert friend.username in out
result = run(cli.accounts.unblock, friend.username)
assert result.exit_code == 0
out = result.stdout.strip()
assert out == f"{friend.username} is no longer blocked"
result = run(cli.accounts.blocked)
assert result.exit_code == 0
out = result.stdout.strip()
assert out == "No accounts blocked"
def test_block_case_insensitive(friend: User, run):
result = run(cli.accounts.block, friend.username.upper())
assert result.exit_code == 0
out = result.stdout.strip()
assert out == f"✓ You are now blocking {friend.username.upper()}"
def test_block_not_found(run):
result = run(cli.accounts.block, "doesnotexistperson")
assert result.exit_code == 1
assert result.stderr.strip() == "Error: Account not found"
def test_block_json(app: App, user: User, friend: User, run_json, friend_id):
# Make sure we're not initially blocking friend
api.unblock(app, user, friend_id)
result = run_json(cli.accounts.blocked, "--json")
assert result == []
result = run_json(cli.accounts.block, friend.username, "--json")
relationship = from_dict(Relationship, result)
assert relationship.id == friend_id
assert relationship.blocking is True
[result] = run_json(cli.accounts.blocked, "--json")
account = from_dict(Account, result)
assert account.id == friend_id
result = run_json(cli.accounts.unblock, friend.username, "--json")
relationship = from_dict(Relationship, result)
assert relationship.id == friend_id
assert relationship.blocking is False
result = run_json(cli.accounts.blocked, "--json")
assert result == []

Wyświetl plik

@ -0,0 +1,217 @@
from typing import Any, Dict
from unittest import mock
from unittest.mock import MagicMock
from toot import User, cli
from tests.integration.conftest import Run
# TODO: figure out how to test login
EMPTY_CONFIG: Dict[Any, Any] = {
"apps": {},
"users": {},
"active_user": None
}
SAMPLE_CONFIG = {
"active_user": "frank@foo.social",
"apps": {
"foo.social": {
"base_url": "http://foo.social",
"client_id": "123",
"client_secret": "123",
"instance": "foo.social"
},
"bar.social": {
"base_url": "http://bar.social",
"client_id": "123",
"client_secret": "123",
"instance": "bar.social"
},
},
"users": {
"frank@foo.social": {
"access_token": "123",
"instance": "foo.social",
"username": "frank"
},
"frank@bar.social": {
"access_token": "123",
"instance": "bar.social",
"username": "frank"
},
}
}
def test_env(run: Run):
result = run(cli.auth.env)
assert result.exit_code == 0
assert "toot" in result.stdout
assert "Python" in result.stdout
@mock.patch("toot.config.load_config")
def test_auth_empty(load_config: MagicMock, run: Run):
load_config.return_value = EMPTY_CONFIG
result = run(cli.auth.auth)
assert result.exit_code == 0
assert result.stdout.strip() == "You are not logged in to any accounts"
@mock.patch("toot.config.load_config")
def test_auth_full(load_config: MagicMock, run: Run):
load_config.return_value = SAMPLE_CONFIG
result = run(cli.auth.auth)
assert result.exit_code == 0
assert result.stdout.strip().startswith("Authenticated accounts:")
assert "frank@foo.social" in result.stdout
assert "frank@bar.social" in result.stdout
# Saving config is mocked so we don't mess up our local config
# TODO: could this be implemented using an auto-use fixture so we have it always
# mocked?
@mock.patch("toot.config.load_app")
@mock.patch("toot.config.save_app")
@mock.patch("toot.config.save_user")
def test_login_cli(
save_user: MagicMock,
save_app: MagicMock,
load_app: MagicMock,
user: User,
run: Run,
):
load_app.return_value = None
result = run(
cli.auth.login_cli,
"--instance", "http://localhost:3000",
"--email", f"{user.username}@example.com",
"--password", "password",
)
assert result.exit_code == 0
assert "✓ Successfully logged in." in result.stdout
save_app.assert_called_once()
(app,) = save_app.call_args.args
assert app.instance == "localhost:3000"
assert app.base_url == "http://localhost:3000"
assert app.client_id
assert app.client_secret
save_user.assert_called_once()
(new_user,) = save_user.call_args.args
assert new_user.instance == "localhost:3000"
assert new_user.username == user.username
# access token will be different since this is a new login
assert new_user.access_token and new_user.access_token != user.access_token
assert save_user.call_args.kwargs == {"activate": True}
@mock.patch("toot.config.load_app")
@mock.patch("toot.config.save_app")
@mock.patch("toot.config.save_user")
def test_login_cli_wrong_password(
save_user: MagicMock,
save_app: MagicMock,
load_app: MagicMock,
user: User,
run: Run,
):
load_app.return_value = None
result = run(
cli.auth.login_cli,
"--instance", "http://localhost:3000",
"--email", f"{user.username}@example.com",
"--password", "wrong password",
)
assert result.exit_code == 1
assert result.stderr.strip() == "Error: Login failed"
save_app.assert_called_once()
(app,) = save_app.call_args.args
assert app.instance == "localhost:3000"
assert app.base_url == "http://localhost:3000"
assert app.client_id
assert app.client_secret
save_user.assert_not_called()
@mock.patch("toot.config.load_config")
@mock.patch("toot.config.delete_user")
def test_logout(delete_user: MagicMock, load_config: MagicMock, run: Run):
load_config.return_value = SAMPLE_CONFIG
result = run(cli.auth.logout, "frank@foo.social")
assert result.exit_code == 0
assert result.stdout.strip() == "✓ Account frank@foo.social logged out"
delete_user.assert_called_once_with(User("foo.social", "frank", "123"))
@mock.patch("toot.config.load_config")
def test_logout_not_logged_in(load_config: MagicMock, run: Run):
load_config.return_value = EMPTY_CONFIG
result = run(cli.auth.logout)
assert result.exit_code == 1
assert result.stderr.strip() == "Error: You're not logged into any accounts"
@mock.patch("toot.config.load_config")
def test_logout_account_not_specified(load_config: MagicMock, run: Run):
load_config.return_value = SAMPLE_CONFIG
result = run(cli.auth.logout)
assert result.exit_code == 1
assert result.stderr.startswith("Error: Specify account to log out")
@mock.patch("toot.config.load_config")
def test_logout_account_does_not_exist(load_config: MagicMock, run: Run):
load_config.return_value = SAMPLE_CONFIG
result = run(cli.auth.logout, "banana")
assert result.exit_code == 1
assert result.stderr.startswith("Error: Account not found")
@mock.patch("toot.config.load_config")
@mock.patch("toot.config.activate_user")
def test_activate(activate_user: MagicMock, load_config: MagicMock, run: Run):
load_config.return_value = SAMPLE_CONFIG
result = run(cli.auth.activate, "frank@foo.social")
assert result.exit_code == 0
assert result.stdout.strip() == "✓ Account frank@foo.social activated"
activate_user.assert_called_once_with(User("foo.social", "frank", "123"))
@mock.patch("toot.config.load_config")
def test_activate_not_logged_in(load_config: MagicMock, run: Run):
load_config.return_value = EMPTY_CONFIG
result = run(cli.auth.activate)
assert result.exit_code == 1
assert result.stderr.strip() == "Error: You're not logged into any accounts"
@mock.patch("toot.config.load_config")
def test_activate_account_not_given(load_config: MagicMock, run: Run):
load_config.return_value = SAMPLE_CONFIG
result = run(cli.auth.activate)
assert result.exit_code == 1
assert result.stderr.startswith("Error: Specify account to activate")
@mock.patch("toot.config.load_config")
def test_activate_invalid_Account(load_config: MagicMock, run: Run):
load_config.return_value = SAMPLE_CONFIG
result = run(cli.auth.activate, "banana")
assert result.exit_code == 1
assert result.stderr.startswith("Error: Account not found")

Wyświetl plik

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

Wyświetl plik

@ -0,0 +1,363 @@
import json
import re
import uuid
from datetime import datetime, timedelta, timezone
from os import path
from tests.integration.conftest import ASSETS_DIR, posted_status_id
from toot import CLIENT_NAME, CLIENT_WEBSITE, api, cli
from toot.utils import get_text
from unittest import mock
def test_post(app, user, run):
text = "i wish i was a #lumberjack"
result = run(cli.post.post, text)
assert result.exit_code == 0
status_id = posted_status_id(result.stdout)
status = api.fetch_status(app, user, status_id).json()
assert text == get_text(status["content"])
assert status["visibility"] == "public"
assert status["sensitive"] is False
assert status["spoiler_text"] == ""
assert status["poll"] is None
# Pleroma doesn't return the application
if status["application"]:
assert status["application"]["name"] == CLIENT_NAME
assert status["application"]["website"] == CLIENT_WEBSITE
def test_post_no_text(run):
result = run(cli.post.post)
assert result.exit_code == 1
assert result.stderr.strip() == "Error: You must specify either text or media to post."
def test_post_json(run):
content = "i wish i was a #lumberjack"
result = run(cli.post.post, content, "--json")
assert result.exit_code == 0
status = json.loads(result.stdout)
assert get_text(status["content"]) == content
assert status["visibility"] == "public"
assert status["sensitive"] is False
assert status["spoiler_text"] == ""
assert status["poll"] is None
def test_post_visibility(app, user, run):
for visibility in ["public", "unlisted", "private", "direct"]:
result = run(cli.post.post, "foo", "--visibility", visibility)
assert result.exit_code == 0
status_id = posted_status_id(result.stdout)
status = api.fetch_status(app, user, status_id).json()
assert status["visibility"] == visibility
def test_post_scheduled_at(app, user, run):
text = str(uuid.uuid4())
scheduled_at = datetime.now(timezone.utc).replace(microsecond=0) + timedelta(minutes=10)
result = run(cli.post.post, text, "--scheduled-at", scheduled_at.isoformat())
assert result.exit_code == 0
assert "Toot scheduled for" in result.stdout
statuses = api.scheduled_statuses(app, user)
[status] = [s for s in statuses if s["params"]["text"] == text]
assert datetime.strptime(status["scheduled_at"], "%Y-%m-%dT%H:%M:%S.%f%z") == scheduled_at
def test_post_scheduled_at_error(run):
result = run(cli.post.post, "foo", "--scheduled-at", "banana")
assert result.exit_code == 1
# Stupid error returned by mastodon
assert result.stderr.strip() == "Error: Record invalid"
def test_post_scheduled_in(app, user, run):
text = str(uuid.uuid4())
variants = [
("1 day", timedelta(days=1)),
("1 day 6 hours", timedelta(days=1, hours=6)),
("1 day 6 hours 13 minutes", timedelta(days=1, hours=6, minutes=13)),
("1 day 6 hours 13 minutes 51 second", timedelta(days=1, hours=6, minutes=13, seconds=51)),
("2d", timedelta(days=2)),
("2d6h", timedelta(days=2, hours=6)),
("2d6h13m", timedelta(days=2, hours=6, minutes=13)),
("2d6h13m51s", timedelta(days=2, hours=6, minutes=13, seconds=51)),
]
datetimes = []
for scheduled_in, delta in variants:
result = run(cli.post.post, text, "--scheduled-in", scheduled_in)
assert result.exit_code == 0
dttm = datetime.utcnow() + delta
assert result.stdout.startswith(f"Toot scheduled for: {str(dttm)[:16]}")
datetimes.append(dttm)
scheduled = api.scheduled_statuses(app, user)
scheduled = [s for s in scheduled if s["params"]["text"] == text]
scheduled = sorted(scheduled, key=lambda s: s["scheduled_at"])
assert len(scheduled) == 8
for expected, status in zip(datetimes, scheduled):
actual = datetime.strptime(status["scheduled_at"], "%Y-%m-%dT%H:%M:%S.%fZ")
delta = expected - actual
assert delta.total_seconds() < 5
def test_post_scheduled_in_invalid_duration(run):
result = run(cli.post.post, "foo", "--scheduled-in", "banana")
assert result.exit_code == 2
assert "Invalid duration: banana" in result.stderr
def test_post_scheduled_in_empty_duration(run):
result = run(cli.post.post, "foo", "--scheduled-in", "0m")
assert result.exit_code == 2
assert "Empty duration" in result.stderr
def test_post_poll(app, user, run):
text = str(uuid.uuid4())
result = run(
cli.post.post, text,
"--poll-option", "foo",
"--poll-option", "bar",
"--poll-option", "baz",
"--poll-option", "qux",
)
assert result.exit_code == 0
status_id = posted_status_id(result.stdout)
status = api.fetch_status(app, user, status_id).json()
assert status["poll"]["expired"] is False
assert status["poll"]["multiple"] is False
assert status["poll"]["options"] == [
{"title": "foo", "votes_count": 0},
{"title": "bar", "votes_count": 0},
{"title": "baz", "votes_count": 0},
{"title": "qux", "votes_count": 0}
]
# Test expires_at is 24h by default
actual = datetime.strptime(status["poll"]["expires_at"], "%Y-%m-%dT%H:%M:%S.%f%z")
expected = datetime.now(timezone.utc) + timedelta(days=1)
delta = actual - expected
assert delta.total_seconds() < 5
def test_post_poll_multiple(app, user, run):
text = str(uuid.uuid4())
result = run(
cli.post.post, text,
"--poll-option", "foo",
"--poll-option", "bar",
"--poll-multiple"
)
assert result.exit_code == 0
status_id = posted_status_id(result.stdout)
status = api.fetch_status(app, user, status_id).json()
assert status["poll"]["multiple"] is True
def test_post_poll_expires_in(app, user, run):
text = str(uuid.uuid4())
result = run(
cli.post.post, text,
"--poll-option", "foo",
"--poll-option", "bar",
"--poll-expires-in", "8h",
)
assert result.exit_code == 0
status_id = posted_status_id(result.stdout)
status = api.fetch_status(app, user, status_id).json()
actual = datetime.strptime(status["poll"]["expires_at"], "%Y-%m-%dT%H:%M:%S.%f%z")
expected = datetime.now(timezone.utc) + timedelta(hours=8)
delta = actual - expected
assert delta.total_seconds() < 5
def test_post_poll_hide_totals(app, user, run):
text = str(uuid.uuid4())
result = run(
cli.post.post, text,
"--poll-option", "foo",
"--poll-option", "bar",
"--poll-hide-totals"
)
assert result.exit_code == 0
status_id = posted_status_id(result.stdout)
status = api.fetch_status(app, user, status_id).json()
# votes_count is None when totals are hidden
assert status["poll"]["options"] == [
{"title": "foo", "votes_count": None},
{"title": "bar", "votes_count": None},
]
def test_post_language(app, user, run):
result = run(cli.post.post, "test", "--language", "hr")
assert result.exit_code == 0
status_id = posted_status_id(result.stdout)
status = api.fetch_status(app, user, status_id).json()
assert status["language"] == "hr"
result = run(cli.post.post, "test", "--language", "zh")
assert result.exit_code == 0
status_id = posted_status_id(result.stdout)
status = api.fetch_status(app, user, status_id).json()
assert status["language"] == "zh"
def test_post_language_error(run):
result = run(cli.post.post, "test", "--language", "banana")
assert result.exit_code == 2
assert "Language should be a two letter abbreviation." in result.stderr
def test_media_thumbnail(app, user, run):
video_path = path.join(ASSETS_DIR, "small.webm")
thumbnail_path = path.join(ASSETS_DIR, "test1.png")
result = run(
cli.post.post,
"--media", video_path,
"--thumbnail", thumbnail_path,
"--description", "foo",
"some text"
)
assert result.exit_code == 0
status_id = posted_status_id(result.stdout)
status = api.fetch_status(app, user, status_id).json()
[media] = status["media_attachments"]
assert media["description"] == "foo"
assert media["type"] == "video"
assert media["url"].endswith(".mp4")
assert media["preview_url"].endswith(".png")
# Video properties
assert int(media["meta"]["original"]["duration"]) == 5
assert media["meta"]["original"]["height"] == 320
assert media["meta"]["original"]["width"] == 560
# Thumbnail properties
assert media["meta"]["small"]["height"] == 50
assert media["meta"]["small"]["width"] == 50
def test_media_attachments(app, user, run):
path1 = path.join(ASSETS_DIR, "test1.png")
path2 = path.join(ASSETS_DIR, "test2.png")
path3 = path.join(ASSETS_DIR, "test3.png")
path4 = path.join(ASSETS_DIR, "test4.png")
result = run(
cli.post.post,
"--media", path1,
"--media", path2,
"--media", path3,
"--media", path4,
"--description", "Test 1",
"--description", "Test 2",
"--description", "Test 3",
"--description", "Test 4",
"some text"
)
assert result.exit_code == 0
status_id = posted_status_id(result.stdout)
status = api.fetch_status(app, user, status_id).json()
[a1, a2, a3, a4] = status["media_attachments"]
# Pleroma doesn't send metadata
if "meta" in a1:
assert a1["meta"]["original"]["size"] == "50x50"
assert a2["meta"]["original"]["size"] == "50x60"
assert a3["meta"]["original"]["size"] == "50x70"
assert a4["meta"]["original"]["size"] == "50x80"
assert a1["description"] == "Test 1"
assert a2["description"] == "Test 2"
assert a3["description"] == "Test 3"
assert a4["description"] == "Test 4"
def test_too_many_media(run):
m = path.join(ASSETS_DIR, "test1.png")
result = run(cli.post.post, "-m", m, "-m", m, "-m", m, "-m", m, "-m", m)
assert result.exit_code == 1
assert result.stderr.strip() == "Error: Cannot attach more than 4 files."
@mock.patch("toot.utils.multiline_input")
@mock.patch("sys.stdin.read")
def test_media_attachment_without_text(mock_read, mock_ml, app, user, run):
# No status from stdin or readline
mock_read.return_value = ""
mock_ml.return_value = ""
media_path = path.join(ASSETS_DIR, "test1.png")
result = run(cli.post.post, "--media", media_path)
assert result.exit_code == 0
status_id = posted_status_id(result.stdout)
status = api.fetch_status(app, user, status_id).json()
assert status["content"] == ""
[attachment] = status["media_attachments"]
assert not attachment["description"]
# Pleroma doesn't send metadata
if "meta" in attachment:
assert attachment["meta"]["original"]["size"] == "50x50"
def test_reply_thread(app, user, friend, run):
status = api.post_status(app, friend, "This is the status").json()
result = run(cli.post.post, "--reply-to", status["id"], "This is the reply")
assert result.exit_code == 0
status_id = posted_status_id(result.stdout)
reply = api.fetch_status(app, user, status_id).json()
assert reply["in_reply_to_id"] == status["id"]
result = run(cli.read.thread, status["id"])
assert result.exit_code == 0
[s1, s2] = [s.strip() for s in re.split(r"─+", result.stdout) if s.strip()]
assert "This is the status" in s1
assert "This is the reply" in s2
assert friend.username in s1
assert user.username in s2
assert status["id"] in s1
assert reply["id"] in s2

Wyświetl plik

@ -0,0 +1,203 @@
import json
import re
from tests.integration.conftest import TOOT_TEST_BASE_URL
from toot import api, cli
from toot.entities import Account, Status, from_dict, from_dict_list
from uuid import uuid4
def test_instance_default(app, run):
result = run(cli.read.instance)
assert result.exit_code == 0
assert "Mastodon" in result.stdout
assert app.instance in result.stdout
assert "running Mastodon" in result.stdout
def test_instance_with_url(app, run):
result = run(cli.read.instance, TOOT_TEST_BASE_URL)
assert result.exit_code == 0
assert "Mastodon" in result.stdout
assert app.instance in result.stdout
assert "running Mastodon" in result.stdout
def test_instance_json(app, run):
result = run(cli.read.instance, "--json")
assert result.exit_code == 0
data = json.loads(result.stdout)
assert data["title"] is not None
assert data["description"] is not None
assert data["version"] is not None
def test_instance_anon(app, run_anon, base_url):
result = run_anon(cli.read.instance, base_url)
assert result.exit_code == 0
assert "Mastodon" in result.stdout
assert app.instance in result.stdout
assert "running Mastodon" in result.stdout
# Need to specify the instance name when running anon
result = run_anon(cli.read.instance)
assert result.exit_code == 1
assert result.stderr.strip() == "Error: INSTANCE argument not given and not logged in"
def test_whoami(user, run):
result = run(cli.read.whoami)
assert result.exit_code == 0
assert f"@{user.username}" in result.stdout
def test_whoami_json(user, run):
result = run(cli.read.whoami, "--json")
assert result.exit_code == 0
data = json.loads(result.stdout)
account = from_dict(Account, data)
assert account.username == user.username
assert account.acct == user.username
def test_whois(app, friend, run):
variants = [
friend.username,
f"@{friend.username}",
f"{friend.username}@{app.instance}",
f"@{friend.username}@{app.instance}",
]
for username in variants:
result = run(cli.read.whois, username)
assert result.exit_code == 0
assert f"@{friend.username}" in result.stdout
def test_whois_json(app, friend, run):
result = run(cli.read.whois, friend.username, "--json")
assert result.exit_code == 0
data = json.loads(result.stdout)
account = from_dict(Account, data)
assert account.username == friend.username
assert account.acct == friend.username
def test_search_account(friend, run):
result = run(cli.read.search, friend.username)
assert result.exit_code == 0
assert result.stdout.strip() == f"Accounts:\n* @{friend.username}"
def test_search_account_json(friend, run):
result = run(cli.read.search, friend.username, "--json")
assert result.exit_code == 0
data = json.loads(result.stdout)
[account] = from_dict_list(Account, data["accounts"])
assert account.acct == friend.username
def test_search_hashtag(app, user, run):
api.post_status(app, user, "#hashtag_x")
api.post_status(app, user, "#hashtag_y")
api.post_status(app, user, "#hashtag_z")
result = run(cli.read.search, "#hashtag")
assert result.exit_code == 0
assert result.stdout.strip() == "Hashtags:\n#hashtag_x, #hashtag_y, #hashtag_z"
def test_search_hashtag_json(app, user, run):
api.post_status(app, user, "#hashtag_x")
api.post_status(app, user, "#hashtag_y")
api.post_status(app, user, "#hashtag_z")
result = run(cli.read.search, "#hashtag", "--json")
assert result.exit_code == 0
data = json.loads(result.stdout)
[h1, h2, h3] = sorted(data["hashtags"], key=lambda h: h["name"])
assert h1["name"] == "hashtag_x"
assert h2["name"] == "hashtag_y"
assert h3["name"] == "hashtag_z"
def test_status(app, user, run):
uuid = str(uuid4())
status_id = api.post_status(app, user, uuid).json()["id"]
result = run(cli.read.status, status_id)
assert result.exit_code == 0
out = result.stdout.strip()
assert uuid in out
assert user.username in out
assert status_id in out
def test_status_json(app, user, run):
uuid = str(uuid4())
status_id = api.post_status(app, user, uuid).json()["id"]
result = run(cli.read.status, status_id, "--json")
assert result.exit_code == 0
status = from_dict(Status, json.loads(result.stdout))
assert status.id == status_id
assert status.account.acct == user.username
assert uuid in status.content
def test_thread(app, user, run):
uuid1 = str(uuid4())
uuid2 = str(uuid4())
uuid3 = str(uuid4())
s1 = api.post_status(app, user, uuid1).json()
s2 = api.post_status(app, user, uuid2, in_reply_to_id=s1["id"]).json()
s3 = api.post_status(app, user, uuid3, in_reply_to_id=s2["id"]).json()
for status in [s1, s2, s3]:
result = run(cli.read.thread, status["id"])
assert result.exit_code == 0
bits = re.split(r"─+", result.stdout.strip())
bits = [b for b in bits if b]
assert len(bits) == 3
assert s1["id"] in bits[0]
assert s2["id"] in bits[1]
assert s3["id"] in bits[2]
assert uuid1 in bits[0]
assert uuid2 in bits[1]
assert uuid3 in bits[2]
def test_thread_json(app, user, run):
uuid1 = str(uuid4())
uuid2 = str(uuid4())
uuid3 = str(uuid4())
s1 = api.post_status(app, user, uuid1).json()
s2 = api.post_status(app, user, uuid2, in_reply_to_id=s1["id"]).json()
s3 = api.post_status(app, user, uuid3, in_reply_to_id=s2["id"]).json()
result = run(cli.read.thread, s2["id"], "--json")
assert result.exit_code == 0
result = json.loads(result.stdout)
[ancestor] = [from_dict(Status, s) for s in result["ancestors"]]
[descendent] = [from_dict(Status, s) for s in result["descendants"]]
assert ancestor.id == s1["id"]
assert descendent.id == s3["id"]

Wyświetl plik

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

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

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

Wyświetl plik

@ -1,518 +0,0 @@
"""
This module contains integration tests meant to run against a test Mastodon instance.
You can set up a test instance locally by following this guide:
https://docs.joinmastodon.org/dev/setup/
To enable integration tests, export the following environment variables to match
your test server and database:
```
export TOOT_TEST_HOSTNAME="localhost:3000"
export TOOT_TEST_DATABASE_DSN="dbname=mastodon_development"
```
"""
import os
import psycopg2
import pytest
import re
import time
import uuid
from datetime import datetime, timedelta, timezone
from os import path
from toot import CLIENT_NAME, CLIENT_WEBSITE, api, App, User
from toot.console import run_command
from toot.exceptions import ConsoleError, NotFoundError
from toot.utils import get_text
from unittest import mock
# Host name of a test instance to run integration tests against
# DO NOT USE PUBLIC INSTANCES!!!
HOSTNAME = os.getenv("TOOT_TEST_HOSTNAME")
# Mastodon database name, used to confirm user registration without having to click the link
DATABASE_DSN = os.getenv("TOOT_TEST_DATABASE_DSN")
if not HOSTNAME or not DATABASE_DSN:
pytest.skip("Skipping integration tests", allow_module_level=True)
# ------------------------------------------------------------------------------
# Fixtures
# ------------------------------------------------------------------------------
def create_app():
response = api.create_app(HOSTNAME, scheme="http")
return App(HOSTNAME, f"http://{HOSTNAME}", response["client_id"], response["client_secret"])
def register_account(app: App):
username = str(uuid.uuid4())[-10:]
email = f"{username}@example.com"
response = api.register_account(app, username, email, "password", "en")
confirm_user(email)
return User(app.instance, username, response["access_token"])
def confirm_user(email):
conn = psycopg2.connect(DATABASE_DSN)
cursor = conn.cursor()
cursor.execute("UPDATE users SET confirmed_at = now() WHERE email = %s;", (email,))
conn.commit()
@pytest.fixture(scope="session")
def app():
return create_app()
@pytest.fixture(scope="session")
def user(app):
return register_account(app)
@pytest.fixture(scope="session")
def friend(app):
return register_account(app)
@pytest.fixture
def run(app, user, capsys):
def _run(command, *params, as_user=None):
run_command(app, as_user or user, command, params or [])
out, err = capsys.readouterr()
assert err == ""
return strip_ansi(out)
return _run
@pytest.fixture
def run_anon(capsys):
def _run(command, *params):
run_command(None, None, command, params or [])
out, err = capsys.readouterr()
assert err == ""
return strip_ansi(out)
return _run
# ------------------------------------------------------------------------------
# Tests
# ------------------------------------------------------------------------------
def test_instance(app, run):
out = run("instance", "--disable-https")
assert "Mastodon" in out
assert app.instance in out
assert "running Mastodon" in out
def test_instance_anon(app, run_anon):
out = run_anon("instance", "--disable-https", HOSTNAME)
assert "Mastodon" in out
assert app.instance in out
assert "running Mastodon" in out
# Need to specify the instance name when running anon
with pytest.raises(ConsoleError) as exc:
run_anon("instance")
assert str(exc.value) == "Please specify instance name."
def test_post(app, user, run):
text = "i wish i was a #lumberjack"
out = run("post", text)
status_id = _posted_status_id(out)
status = api.fetch_status(app, user, status_id)
assert text == get_text(status["content"])
assert status["visibility"] == "public"
assert status["sensitive"] is False
assert status["spoiler_text"] == ""
# Pleroma doesn't return the application
if status["application"]:
assert status["application"]["name"] == CLIENT_NAME
assert status["application"]["website"] == CLIENT_WEBSITE
def test_post_visibility(app, user, run):
for visibility in ["public", "unlisted", "private", "direct"]:
out = run("post", "foo", "--visibility", visibility)
status_id = _posted_status_id(out)
status = api.fetch_status(app, user, status_id)
assert status["visibility"] == visibility
def test_post_scheduled_at(app, user, run):
text = str(uuid.uuid4())
scheduled_at = datetime.now(timezone.utc).replace(microsecond=0) + timedelta(minutes=10)
out = run("post", text, "--scheduled-at", scheduled_at.isoformat())
assert "Toot scheduled for" in out
statuses = api.scheduled_statuses(app, user)
[status] = [s for s in statuses if s["params"]["text"] == text]
assert datetime.strptime(status["scheduled_at"], "%Y-%m-%dT%H:%M:%S.%f%z") == scheduled_at
def test_post_scheduled_in(app, user, run):
text = str(uuid.uuid4())
variants = [
("1 day", timedelta(days=1)),
("1 day 6 hours", timedelta(days=1, hours=6)),
("1 day 6 hours 13 minutes", timedelta(days=1, hours=6, minutes=13)),
("1 day 6 hours 13 minutes 51 second", timedelta(days=1, hours=6, minutes=13, seconds=51)),
("2d", timedelta(days=2)),
("2d6h", timedelta(days=2, hours=6)),
("2d6h13m", timedelta(days=2, hours=6, minutes=13)),
("2d6h13m51s", timedelta(days=2, hours=6, minutes=13, seconds=51)),
]
datetimes = []
for scheduled_in, delta in variants:
out = run("post", text, "--scheduled-in", scheduled_in)
dttm = datetime.utcnow() + delta
assert out.startswith(f"Toot scheduled for: {str(dttm)[:16]}")
datetimes.append(dttm)
scheduled = api.scheduled_statuses(app, user)
scheduled = [s for s in scheduled if s["params"]["text"] == text]
scheduled = sorted(scheduled, key=lambda s: s["scheduled_at"])
assert len(scheduled) == 8
for expected, status in zip(datetimes, scheduled):
actual = datetime.strptime(status["scheduled_at"], "%Y-%m-%dT%H:%M:%S.%fZ")
delta = expected - actual
assert delta.total_seconds() < 5
def test_post_language(app, user, run):
out = run("post", "test", "--language", "hr")
status_id = _posted_status_id(out)
status = api.fetch_status(app, user, status_id)
assert status["language"] == "hr"
out = run("post", "test", "--language", "zh")
status_id = _posted_status_id(out)
status = api.fetch_status(app, user, status_id)
assert status["language"] == "zh"
def test_media_attachments(app, user, run):
assets_dir = path.realpath(path.join(path.dirname(__file__), "assets"))
path1 = path.join(assets_dir, "test1.png")
path2 = path.join(assets_dir, "test2.png")
path3 = path.join(assets_dir, "test3.png")
path4 = path.join(assets_dir, "test4.png")
out = run(
"post",
"--media", path1,
"--media", path2,
"--media", path3,
"--media", path4,
"--description", "Test 1",
"--description", "Test 2",
"--description", "Test 3",
"--description", "Test 4",
"some text"
)
status_id = _posted_status_id(out)
status = api.fetch_status(app, user, status_id)
[a1, a2, a3, a4] = status["media_attachments"]
# Pleroma doesn't send metadata
if "meta" in a1:
assert a1["meta"]["original"]["size"] == "50x50"
assert a2["meta"]["original"]["size"] == "50x60"
assert a3["meta"]["original"]["size"] == "50x70"
assert a4["meta"]["original"]["size"] == "50x80"
assert a1["description"] == "Test 1"
assert a2["description"] == "Test 2"
assert a3["description"] == "Test 3"
assert a4["description"] == "Test 4"
@mock.patch("toot.utils.multiline_input")
@mock.patch("sys.stdin.read")
def test_media_attachment_without_text(mock_read, mock_ml, app, user, run):
# No status from stdin or readline
mock_read.return_value = ""
mock_ml.return_value = ""
assets_dir = path.realpath(path.join(path.dirname(__file__), "assets"))
media_path = path.join(assets_dir, "test1.png")
out = run("post", "--media", media_path)
status_id = _posted_status_id(out)
status = api.fetch_status(app, user, status_id)
assert status["content"] == ""
[attachment] = status["media_attachments"]
assert not attachment["description"]
# Pleroma doesn't send metadata
if "meta" in attachment:
assert attachment["meta"]["original"]["size"] == "50x50"
def test_delete_status(app, user, run):
status = api.post_status(app, user, "foo")
out = run("delete", status["id"])
assert out == "✓ Status deleted"
with pytest.raises(NotFoundError):
api.fetch_status(app, user, status["id"])
def test_reply_thread(app, user, friend, run):
status = api.post_status(app, friend, "This is the status")
out = run("post", "--reply-to", status["id"], "This is the reply")
status_id = _posted_status_id(out)
reply = api.fetch_status(app, user, status_id)
assert reply["in_reply_to_id"] == status["id"]
out = run("thread", status["id"])
[s1, s2] = [s.strip() for s in re.split(r"─+", out) if s.strip()]
assert "This is the status" in s1
assert "This is the reply" in s2
assert friend.username in s1
assert user.username in s2
assert status["id"] in s1
assert reply["id"] in s2
def test_favourite(app, user, run):
status = api.post_status(app, user, "foo")
assert not status["favourited"]
out = run("favourite", status["id"])
assert out == "✓ Status favourited"
status = api.fetch_status(app, user, status["id"])
assert status["favourited"]
out = run("unfavourite", status["id"])
assert out == "✓ Status unfavourited"
# A short delay is required before the server returns new data
time.sleep(0.1)
status = api.fetch_status(app, user, status["id"])
assert not status["favourited"]
def test_reblog(app, user, run):
status = api.post_status(app, user, "foo")
assert not status["reblogged"]
out = run("reblog", status["id"])
assert out == "✓ Status reblogged"
status = api.fetch_status(app, user, status["id"])
assert status["reblogged"]
out = run("reblogged_by", status["id"])
assert out == f"@{user.username}"
out = run("unreblog", status["id"])
assert out == "✓ Status unreblogged"
status = api.fetch_status(app, user, status["id"])
assert not status["reblogged"]
def test_pin(app, user, run):
status = api.post_status(app, user, "foo")
assert not status["pinned"]
out = run("pin", status["id"])
assert out == "✓ Status pinned"
status = api.fetch_status(app, user, status["id"])
assert status["pinned"]
out = run("unpin", status["id"])
assert out == "✓ Status unpinned"
status = api.fetch_status(app, user, status["id"])
assert not status["pinned"]
def test_bookmark(app, user, run):
status = api.post_status(app, user, "foo")
assert not status["bookmarked"]
out = run("bookmark", status["id"])
assert out == "✓ Status bookmarked"
status = api.fetch_status(app, user, status["id"])
assert status["bookmarked"]
out = run("unbookmark", status["id"])
assert out == "✓ Status unbookmarked"
status = api.fetch_status(app, user, status["id"])
assert not status["bookmarked"]
def test_whoami(user, run):
out = run("whoami")
# TODO: test other fields once updating account is supported
assert f"@{user.username}" in out
assert f"http://{HOSTNAME}/@{user.username}" in out
def test_whois(app, friend, run):
variants = [
friend.username,
f"@{friend.username}",
f"{friend.username}@{app.instance}",
f"@{friend.username}@{app.instance}",
]
for username in variants:
out = run("whois", username)
assert f"@{friend.username}" in out
assert f"http://{HOSTNAME}/@{friend.username}" in out
def test_search_account(friend, run):
out = run("search", friend.username)
assert out == f"Accounts:\n* @{friend.username}"
def test_search_hashtag(app, user, run):
api.post_status(app, user, "#hashtag_x")
api.post_status(app, user, "#hashtag_y")
api.post_status(app, user, "#hashtag_z")
out = run("search", "#hashtag")
assert out == "Hashtags:\n#hashtag_x, #hashtag_y, #hashtag_z"
def test_follow(friend, run):
out = run("follow", friend.username)
assert out == f"✓ You are now following {friend.username}"
out = run("unfollow", friend.username)
assert out == f"✓ You are no longer following {friend.username}"
def test_follow_case_insensitive(friend, run):
username = friend.username.upper()
out = run("follow", username)
assert out == f"✓ You are now following {username}"
out = run("unfollow", username)
assert out == f"✓ You are no longer following {username}"
# TODO: improve testing stderr, catching exceptions is not optimal
def test_follow_not_found(run):
with pytest.raises(ConsoleError) as ex_info:
run("follow", "banana")
assert str(ex_info.value) == "Account not found"
def test_mute(app, user, friend, run):
out = run("mute", friend.username)
assert out == f"✓ You have muted {friend.username}"
[muted_account] = api.get_muted_accounts(app, user)
assert muted_account["acct"] == friend.username
out = run("unmute", friend.username)
assert out == f"{friend.username} is no longer muted"
assert api.get_muted_accounts(app, user) == []
def test_block(app, user, friend, run):
out = run("block", friend.username)
assert out == f"✓ You are now blocking {friend.username}"
[blockd_account] = api.get_blocked_accounts(app, user)
assert blockd_account["acct"] == friend.username
out = run("unblock", friend.username)
assert out == f"{friend.username} is no longer blocked"
assert api.get_blocked_accounts(app, user) == []
def test_following_followers(user, friend, run):
out = run("following", user.username)
assert out == ""
run("follow", friend.username)
out = run("following", user.username)
assert out == f"* @{friend.username}"
out = run("followers", friend.username)
assert out == f"* @{user.username}"
def test_tags(run):
out = run("tags_followed")
assert out == "You're not following any hashtags."
out = run("tags_follow", "foo")
assert out == "✓ You are now following #foo"
out = run("tags_followed")
assert out == "* #foo\thttp://localhost:3000/tags/foo"
out = run("tags_follow", "bar")
assert out == "✓ You are now following #bar"
out = run("tags_followed")
assert out == "\n".join([
"* #bar\thttp://localhost:3000/tags/bar",
"* #foo\thttp://localhost:3000/tags/foo",
])
out = run("tags_unfollow", "foo")
assert out == "✓ You are no longer following #foo"
out = run("tags_followed")
assert out == "* #bar\thttp://localhost:3000/tags/bar"
# ------------------------------------------------------------------------------
# Utils
# ------------------------------------------------------------------------------
strip_ansi_pattern = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
def strip_ansi(string):
return strip_ansi_pattern.sub("", string).strip()
def _posted_status_id(out):
pattern = re.compile(r"Toot posted: http://([^/]+)/([^/]+)/(.+)")
match = re.search(pattern, out)
assert match
host, _, status_id = match.groups()
assert host == HOSTNAME
return status_id

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,9 @@
from argparse import ArgumentTypeError
import click
import pytest
from toot.console import duration
from toot.cli.validators import validate_duration
from toot.wcstring import wc_wrap, trunc, pad, fit_text
from toot.utils import urlencode_url
def test_pad():
@ -162,6 +163,9 @@ def test_wc_wrap_indented():
def test_duration():
def duration(value):
return validate_duration(None, None, value)
# Long hand
assert duration("1 second") == 1
assert duration("1 seconds") == 1
@ -189,15 +193,20 @@ def test_duration():
assert duration("5d 10h 3m 1s") == 5 * 86400 + 10 * 3600 + 3 * 60 + 1
assert duration("5d10h3m1s") == 5 * 86400 + 10 * 3600 + 3 * 60 + 1
with pytest.raises(ArgumentTypeError):
with pytest.raises(click.BadParameter):
duration("")
with pytest.raises(ArgumentTypeError):
with pytest.raises(click.BadParameter):
duration("100")
# Wrong order
with pytest.raises(ArgumentTypeError):
with pytest.raises(click.BadParameter):
duration("1m1d")
with pytest.raises(ArgumentTypeError):
with pytest.raises(click.BadParameter):
duration("banana")
def test_urlencode_url():
assert urlencode_url("https://www.example.com") == "https://www.example.com"
assert urlencode_url("https://www.example.com/url%20with%20spaces") == "https://www.example.com/url%20with%20spaces"

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

Wyświetl plik

@ -1,11 +1,45 @@
from collections import namedtuple
import os
import sys
__version__ = '0.33.1'
from os.path import join, expanduser
from typing import NamedTuple
App = namedtuple('App', ['instance', 'base_url', 'client_id', 'client_secret'])
User = namedtuple('User', ['instance', 'username', 'access_token'])
__version__ = '0.41.1'
DEFAULT_INSTANCE = 'mastodon.social'
class App(NamedTuple):
instance: str
base_url: str
client_id: str
client_secret: str
class User(NamedTuple):
instance: str
username: str
access_token: str
DEFAULT_INSTANCE = 'https://mastodon.social'
CLIENT_NAME = 'toot - a Mastodon CLI client'
CLIENT_WEBSITE = 'https://github.com/ihabunek/toot'
TOOT_CONFIG_DIR_NAME = "toot"
def get_config_dir():
"""Returns the path to toot config directory"""
# On Windows, store the config in roaming appdata
if sys.platform == "win32" and "APPDATA" in os.environ:
return join(os.getenv("APPDATA"), TOOT_CONFIG_DIR_NAME)
# Respect XDG_CONFIG_HOME env variable if set
# https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
if "XDG_CONFIG_HOME" in os.environ:
config_home = expanduser(os.environ["XDG_CONFIG_HOME"])
return join(config_home, TOOT_CONFIG_DIR_NAME)
# Default to ~/.config/toot/
return join(expanduser("~"), ".config", TOOT_CONFIG_DIR_NAME)

3
toot/__main__.py 100644
Wyświetl plik

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

Wyświetl plik

@ -1,46 +1,77 @@
import mimetypes
import re
import uuid
from os import path
from requests import Response
from typing import BinaryIO, List, Optional
from urllib.parse import urlparse, urlencode, quote
from toot import http, CLIENT_NAME, CLIENT_WEBSITE
from toot.exceptions import AuthenticationError, ApiError
from toot.utils import str_bool
from toot import App, User, http, CLIENT_NAME, CLIENT_WEBSITE
from toot.exceptions import ApiError, ConsoleError
from toot.utils import drop_empty_values, str_bool, str_bool_nullable
SCOPES = 'read write follow'
def _account_action(app, user, account, action):
def find_account(app, user, account_name):
if not account_name:
raise ConsoleError("Empty account name given")
normalized_name = account_name.lstrip("@").lower()
# Strip @<instance_name> from accounts on the local instance. The `acct`
# field in account object contains the qualified name for users of other
# instances, but only the username for users of the local instance. This is
# required in order to match the account name below.
if "@" in normalized_name:
[username, instance] = normalized_name.split("@", maxsplit=1)
if instance == app.instance:
normalized_name = username
response = search(app, user, account_name, type="accounts", resolve=True)
for account in response.json()["accounts"]:
if account["acct"].lower() == normalized_name:
return account
raise ConsoleError("Account not found")
def _account_action(app, user, account, action) -> Response:
url = f"/api/v1/accounts/{account}/{action}"
return http.post(app, user, url).json()
return http.post(app, user, url)
def _status_action(app, user, status_id, action, data=None):
def _status_action(app, user, status_id, action, data=None) -> Response:
url = f"/api/v1/statuses/{status_id}/{action}"
return http.post(app, user, url, data=data).json()
return http.post(app, user, url, data=data)
def _tag_action(app, user, tag_name, action):
def _tag_action(app, user, tag_name, action) -> Response:
url = f"/api/v1/tags/{tag_name}/{action}"
return http.post(app, user, url).json()
return http.post(app, user, url)
def _status_toggle_action(app, user, status_id, action, data=None):
url = '/api/v1/statuses/{}/{}'.format(status_id, action)
try:
response = http.post(app, user, url, data=data).json()
response = http.post(app, user, url).json()
except ApiError as e:
# For "toggle" operations, Mastodon returns unhelpful
# 422: "Validation failed:"
# 422: "Validation failed: Status has already been taken"
# responses when you try to bookmark a status already
# bookmarked, or favourite a status already favourited
# so we just swallow those errors here
if str(e).startswith("Validation failed:"):
return None # FIXME: return mock OK Response object?
if str(e) == "Validation failed: Status has already been taken":
response = None
else:
# not the error we expected; re-raise the exception
raise e
return response
finally:
return response
def create_app(domain, scheme='https'):
url = f"{scheme}://{domain}/api/v1/apps"
@ -83,6 +114,40 @@ def register_account(app, username, email, password, locale="en", agreement=True
return http.anon_post(url, json=json, headers=headers).json()
def update_account(
app,
user,
display_name=None,
note=None,
avatar=None,
header=None,
bot=None,
discoverable=None,
locked=None,
privacy=None,
sensitive=None,
language=None
):
"""
Update account credentials
https://docs.joinmastodon.org/methods/accounts/#update_credentials
"""
files = drop_empty_values({"avatar": avatar, "header": header})
data = drop_empty_values({
"bot": str_bool_nullable(bot),
"discoverable": str_bool_nullable(discoverable),
"display_name": display_name,
"locked": str_bool_nullable(locked),
"note": note,
"source[language]": language,
"source[privacy]": privacy,
"source[sensitive]": str_bool_nullable(sensitive),
})
return http.patch(app, user, "/api/v1/accounts/update_credentials", files=files, data=data)
def fetch_app_token(app):
json = {
"client_id": app.client_id,
@ -95,7 +160,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 = {
@ -107,16 +172,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",
@ -126,7 +185,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 = {
@ -144,7 +203,7 @@ def post_status(
app,
user,
status,
visibility='public',
visibility=None,
media_ids=None,
sensitive=False,
spoiler_text=None,
@ -152,7 +211,11 @@ def post_status(
language=None,
scheduled_at=None,
content_type=None,
):
poll_options=None,
poll_expires_in=None,
poll_multiple=None,
poll_hide_totals=None,
) -> Response:
"""
Publish a new status.
https://docs.joinmastodon.org/methods/statuses/#create
@ -162,7 +225,9 @@ def post_status(
# if the request is retried.
headers = {"Idempotency-Key": uuid.uuid4().hex}
json = {
# Strip keys for which value is None
# Sending null values doesn't bother Mastodon, but it breaks Pleroma
data = drop_empty_values({
'status': status,
'media_ids': media_ids,
'visibility': visibility,
@ -171,14 +236,64 @@ def post_status(
'language': language,
'scheduled_at': scheduled_at,
'content_type': content_type,
'spoiler_text': spoiler_text
}
'spoiler_text': spoiler_text,
})
if poll_options:
data["poll"] = {
"options": poll_options,
"expires_in": poll_expires_in,
"multiple": poll_multiple,
"hide_totals": poll_hide_totals,
}
return http.post(app, user, '/api/v1/statuses', json=data, headers=headers)
def edit_status(
app,
user,
id,
status,
visibility='public',
media_ids=None,
sensitive=False,
spoiler_text=None,
in_reply_to_id=None,
language=None,
content_type=None,
poll_options=None,
poll_expires_in=None,
poll_multiple=None,
poll_hide_totals=None,
) -> Response:
"""
Edit an existing status
https://docs.joinmastodon.org/methods/statuses/#edit
"""
# Strip keys for which value is None
# Sending null values doesn't bother Mastodon, but it breaks Pleroma
json = {k: v for k, v in json.items() if v is not None}
data = drop_empty_values({
'status': status,
'media_ids': media_ids,
'visibility': visibility,
'sensitive': sensitive,
'in_reply_to_id': in_reply_to_id,
'language': language,
'content_type': content_type,
'spoiler_text': spoiler_text,
})
return http.post(app, user, '/api/v1/statuses', json=json, headers=headers).json()
if poll_options:
data["poll"] = {
"options": poll_options,
"expires_in": poll_expires_in,
"multiple": poll_multiple,
"hide_totals": poll_hide_totals,
}
return http.put(app, user, f"/api/v1/statuses/{id}", json=data)
def fetch_status(app, user, id):
@ -186,7 +301,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):
@ -243,14 +367,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):
@ -262,6 +408,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)
@ -269,9 +423,26 @@ def _timeline_generator(app, user, path, params=None):
path = _get_next_path(response.headers)
def _notification_timeline_generator(app, user, path, params=None):
while path:
response = http.get(app, user, path, params)
notification = response.json()
yield [n["status"] for n in notification if n["status"]]
path = _get_next_path(response.headers)
def _conversation_timeline_generator(app, user, path, params=None):
while path:
response = http.get(app, user, path, params)
conversation = response.json()
yield [c["last_status"] for c in conversation if c["last_status"]]
path = _get_next_path(response.headers)
def home_timeline_generator(app, user, limit=20):
path = f"/api/v1/timelines/home?limit={limit}"
return _timeline_generator(app, user, path)
path = "/api/v1/timelines/home"
params = {"limit": limit}
return _timeline_generator(app, user, path, params)
def public_timeline_generator(app, user, local=False, limit=20):
@ -292,36 +463,88 @@ def bookmark_timeline_generator(app, user, limit=20):
return _timeline_generator(app, user, path, params)
def notification_timeline_generator(app, user, limit=20):
# exclude all but mentions and statuses
exclude_types = ["follow", "favourite", "reblog", "poll", "follow_request"]
params = {"exclude_types[]": exclude_types, "limit": limit}
return _notification_timeline_generator(app, user, "/api/v1/notifications", params)
def conversation_timeline_generator(app, user, limit=20):
path = "/api/v1/conversations"
params = {"limit": limit}
return _conversation_timeline_generator(app, user, path, params)
def account_timeline_generator(app, user, account_name: str, replies=False, reblogs=False, limit=20):
account = find_account(app, user, account_name)
path = f"/api/v1/accounts/{account['id']}/statuses"
params = {"limit": limit, "exclude_replies": not replies, "exclude_reblogs": not reblogs}
return _timeline_generator(app, user, path, params)
def timeline_list_generator(app, user, list_id, limit=20):
path = f"/api/v1/timelines/list/{list_id}"
return _timeline_generator(app, user, path, {'limit': limit})
def _anon_timeline_generator(instance, path, params=None):
while path:
url = f"https://{instance}{path}"
def _anon_timeline_generator(url, params=None):
while url:
response = http.anon_get(url, params)
yield response.json()
path = _get_next_path(response.headers)
url = _get_next_url(response.headers)
def anon_public_timeline_generator(instance, local=False, limit=20):
path = '/api/v1/timelines/public'
params = {'local': str_bool(local), 'limit': limit}
return _anon_timeline_generator(instance, path, params)
def anon_public_timeline_generator(base_url, local=False, limit=20):
query = urlencode({"local": str_bool(local), "limit": limit})
url = f"{base_url}/api/v1/timelines/public?{query}"
return _anon_timeline_generator(url)
def anon_tag_timeline_generator(instance, hashtag, local=False, limit=20):
path = f"/api/v1/timelines/tag/{quote(hashtag)}"
params = {'local': str_bool(local), 'limit': limit}
return _anon_timeline_generator(instance, path, params)
def anon_tag_timeline_generator(base_url, hashtag, local=False, limit=20):
query = urlencode({"local": str_bool(local), "limit": limit})
url = f"{base_url}/api/v1/timelines/tag/{quote(hashtag)}?{query}"
return _anon_timeline_generator(url)
def upload_media(app, user, file, description=None):
return http.post(app, user, '/api/v1/media',
data={'description': description},
files={'file': file}
).json()
def get_media(app: App, user: User, id: str):
return http.get(app, user, f"/api/v1/media/{id}").json()
def upload_media(
app: App,
user: User,
media: BinaryIO,
description: Optional[str] = None,
thumbnail: Optional[BinaryIO] = None,
):
data = drop_empty_values({"description": description})
# NB: Documentation says that "file" should provide a mime-type which we
# don't do currently, but it works.
files = drop_empty_values({
"file": media,
"thumbnail": _add_mime_type(thumbnail)
})
return http.post(app, user, "/api/v2/media", data=data, files=files)
def _add_mime_type(file):
if file is None:
return None
# TODO: mimetypes uses the file extension to guess the mime type which is
# not always good enough (e.g. files without extension). python-magic could
# be used instead but it requires adding it as a dependency.
mime_type = mimetypes.guess_type(file.name)
if not mime_type:
raise ConsoleError(f"Unable guess mime type of '{file.name}'. "
"Ensure the file has the desired extension.")
filename = path.basename(file.name)
return (filename, file, mime_type)
def search(app, user, query, resolve=False, type=None):
@ -329,11 +552,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):
@ -344,11 +569,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')
@ -376,6 +601,58 @@ def followed_tags(app, user):
return _get_response_list(app, user, path)
def featured_tags(app, user):
return http.get(app, user, "/api/v1/featured_tags")
def feature_tag(app, user, tag: str) -> Response:
return http.post(app, user, "/api/v1/featured_tags", data={"name": tag})
def unfeature_tag(app, user, tag_id: str) -> Response:
return http.delete(app, user, f"/api/v1/featured_tags/{tag_id}")
def find_tag(app, user, tag) -> Optional[dict]:
"""Find a hashtag by tag name or ID"""
tag = tag.lstrip("#")
results = search(app, user, tag, type="hashtags").json()
return next(
(
t for t in results["hashtags"]
if t["name"].lower() == tag.lstrip("#").lower() or t["id"] == tag
),
None
)
def find_featured_tag(app, user, tag) -> Optional[dict]:
"""Find a featured tag by tag name or ID"""
return next(
(
t for t in featured_tags(app, user).json()
if t["name"].lower() == tag.lstrip("#").lower() or t["id"] == tag
),
None
)
def whois(app, user, account):
return http.get(app, user, f'/api/v1/accounts/{account}').json()
def vote(app, user, poll_id, choices: List[int]):
url = f"/api/v1/polls/{poll_id}/votes"
json = {'choices': choices}
return http.post(app, user, url, json=json).json()
def get_relationship(app, user, account):
params = {"id[]": account}
return http.get(app, user, '/api/v1/accounts/relationships', params).json()[0]
def mute(app, user, account):
return _account_action(app, user, account, 'mute')
@ -384,6 +661,10 @@ def unmute(app, user, account):
return _account_action(app, user, account, 'unmute')
def muted(app, user):
return _get_response_list(app, user, "/api/v1/mutes")
def block(app, user, account):
return _account_action(app, user, account, 'block')
@ -392,17 +673,16 @@ def unblock(app, user, account):
return _account_action(app, user, account, 'unblock')
def verify_credentials(app, user):
return http.get(app, user, '/api/v1/accounts/verify_credentials').json()
def blocked(app, user):
return _get_response_list(app, user, "/api/v1/blocks")
def single_status(app, user, status_id):
url = f"/api/v1/statuses/{status_id}"
return http.get(app, user, url).json()
def verify_credentials(app, user) -> Response:
return http.get(app, user, '/api/v1/accounts/verify_credentials')
def get_notifications(app, user, exclude_types=[], limit=20):
params = {"exclude_types[]": exclude_types, "limit": limit}
def get_notifications(app, user, types=[], exclude_types=[], limit=20):
params = {"types[]": types, "exclude_types[]": exclude_types, "limit": limit}
return http.get(app, user, '/api/v1/notifications', params).json()
@ -410,6 +690,43 @@ def clear_notifications(app, user):
http.post(app, user, '/api/v1/notifications/clear')
def get_instance(domain, scheme="https"):
url = f"{scheme}://{domain}/api/v1/instance"
return http.anon_get(url).json()
def get_instance(base_url: str) -> Response:
url = f"{base_url}/api/v1/instance"
return http.anon_get(url)
def get_preferences(app, user) -> Response:
return http.get(app, user, '/api/v1/preferences')
def get_lists(app, user):
return http.get(app, user, "/api/v1/lists").json()
def get_list_accounts(app, user, list_id):
path = f"/api/v1/lists/{list_id}/accounts"
return _get_response_list(app, user, path)
def create_list(app, user, title, replies_policy="none"):
url = "/api/v1/lists"
json = {'title': title}
if replies_policy:
json['replies_policy'] = replies_policy
return http.post(app, user, url, json=json)
def delete_list(app, user, id):
return http.delete(app, user, f"/api/v1/lists/{id}")
def add_accounts_to_list(app, user, list_id, account_ids):
url = f"/api/v1/lists/{list_id}/accounts"
json = {'account_ids': account_ids}
return http.post(app, user, url, json=json)
def remove_accounts_from_list(app, user, list_id, account_ids):
url = f"/api/v1/lists/{list_id}/accounts"
json = {'account_ids': account_ids}
return http.delete(app, user, url, json=json)

Wyświetl plik

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

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

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

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

293
toot/cli/post.py 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(ctx.app, ctx.user, media, descriptions, thumbnails)
status_text = _get_status_text(text, editor, media)
scheduled_at = _get_scheduled_at(scheduled_at, scheduled_in)
if not status_text and not media_ids:
raise click.ClickException("You must specify either text or media to post.")
response = api.post_status(
app,
user,
status_text,
visibility=visibility,
media_ids=media_ids,
sensitive=sensitive,
spoiler_text=spoiler_text,
in_reply_to_id=reply_to,
language=language,
scheduled_at=scheduled_at,
content_type=content_type,
poll_options=poll_option,
poll_expires_in=poll_expires_in,
poll_multiple=poll_multiple,
poll_hide_totals=poll_hide_totals,
)
if json:
click.echo(response.text)
else:
status = response.json()
if "scheduled_at" in status:
scheduled_at = parse_datetime(status["scheduled_at"])
scheduled_at = datetime.strftime(scheduled_at, "%Y-%m-%d %H:%M:%S%z")
click.echo(f"Toot scheduled for: {scheduled_at}")
else:
click.echo(f"Toot posted: {status['url']}")
delete_tmp_status_file()
@cli.command()
@click.argument("file", type=click.File(mode="rb"))
@click.option(
"--description", "-d",
help="Plain-text description of the media for accessibility purposes"
)
@json_option
@pass_context
def upload(
ctx: Context,
file: BinaryIO,
description: Optional[str],
json: bool,
):
"""Upload an image or video file
This is probably not very useful, see `toot post --media` instead.
"""
response = _do_upload(ctx.app, ctx.user, file, description, None)
if json:
click.echo(response.text)
else:
media = from_dict(MediaAttachment, response.json())
click.echo()
click.echo(f"Successfully uploaded media ID {media.id}, type '{media.type}'")
click.echo(f"URL: {media.url}")
click.echo(f"Preview URL: {media.preview_url}")
def _get_status_text(text, editor, media):
isatty = sys.stdin.isatty()
if not text and not isatty:
text = sys.stdin.read().rstrip()
if isatty:
if editor:
text = editor_input(editor, text)
elif not text and not media:
click.echo(f"Write or paste your toot. Press {EOF_KEY} to post it.")
text = multiline_input()
return text
def _get_scheduled_at(scheduled_at, scheduled_in):
if scheduled_at:
return scheduled_at
if scheduled_in:
scheduled_at = datetime.now(timezone.utc) + timedelta(seconds=scheduled_in)
return scheduled_at.replace(microsecond=0).isoformat()
return None
def _upload_media(app, user, media, descriptions, thumbnails):
# Match media to corresponding descriptions and thumbnail
media = media or []
descriptions = descriptions or []
thumbnails = thumbnails or []
uploaded_media = []
for idx, file in enumerate(media):
description = descriptions[idx].strip() if idx < len(descriptions) else None
thumbnail = thumbnails[idx] if idx < len(thumbnails) else None
result = _do_upload(app, user, file, description, thumbnail).json()
uploaded_media.append(result)
_wait_until_all_processed(app, user, uploaded_media)
return [m["id"] for m in uploaded_media]
def _do_upload(app, user, file, description, thumbnail):
return api.upload_media(app, user, file, description=description, thumbnail=thumbnail)
def _wait_until_all_processed(app, user, uploaded_media):
"""
Media is uploaded asynchronously, and cannot be attached until the server
has finished processing it. This function waits for that to happen.
Once media is processed, it will have the URL populated.
"""
if all(m["url"] for m in uploaded_media):
return
# Timeout after waiting 1 minute
start_time = time()
timeout = 60
click.echo("Waiting for media to finish processing...")
for media in uploaded_media:
_wait_until_processed(app, user, media, start_time, timeout)
def _wait_until_processed(app, user, media, start_time, timeout):
if media["url"]:
return
media = api.get_media(app, user, media["id"])
while not media["url"]:
sleep(1)
if time() > start_time + timeout:
raise click.ClickException(f"Media not processed by server after {timeout} seconds. Aborting.")
media = api.get_media(app, user, media["id"])

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

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

@ -0,0 +1,58 @@
import click
from typing import Optional
from toot.cli import TUI_COLORS, VISIBILITY_CHOICES, Context, cli, pass_context
from toot.cli.validators import validate_tui_colors
from toot.tui.app import TUI, TuiOptions
COLOR_OPTIONS = ", ".join(TUI_COLORS.keys())
@cli.command()
@click.option(
"-r", "--relative-datetimes",
is_flag=True,
help="Show relative datetimes in status list"
)
@click.option(
"-m", "--media-viewer",
help="Program to invoke with media URLs to display the media files, such as 'feh'"
)
@click.option(
"-c", "--colors",
callback=validate_tui_colors,
help=f"""Number of colors to use, one of {COLOR_OPTIONS}, defaults to 16 if
using --color, and 1 if using --no-color."""
)
@click.option(
"-v", "--default-visibility",
type=click.Choice(VISIBILITY_CHOICES),
help="Default visibility when posting new toots; overrides the server-side preference"
)
@click.option(
"-S", "--always-show-sensitive",
is_flag=True,
help="Expand toots with content warnings automatically"
)
@pass_context
def tui(
ctx: Context,
colors: Optional[int],
media_viewer: Optional[str],
always_show_sensitive: bool,
relative_datetimes: bool,
default_visibility: Optional[str]
):
"""Launches the toot terminal user interface"""
if colors is None:
colors = 16 if ctx.color else 1
options = TuiOptions(
colors=colors,
media_viewer=media_viewer,
relative_datetimes=relative_datetimes,
default_visibility=default_visibility,
always_show_sensitive=always_show_sensitive,
)
tui = TUI.create(ctx.app, ctx.user, options)
tui.run()

Wyświetl plik

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

Wyświetl plik

@ -1,420 +0,0 @@
import sys
import platform
from datetime import datetime, timedelta, timezone
from toot import api, config, __version__
from toot.auth import login_interactive, login_browser_interactive, create_app_interactive
from toot.exceptions import ApiError, ConsoleError
from toot.output import (print_out, print_instance, print_account, print_acct_list,
print_search_results, print_timeline, print_notifications,
print_tag_list)
from toot.tui.utils import parse_datetime
from toot.utils import editor_input, multiline_input, EOF_KEY
def get_timeline_generator(app, user, args):
# Make sure tag, list and public are not used simultaneously
if len([arg for arg in [args.tag, args.list, args.public] if arg]) > 1:
raise ConsoleError("Only one of --public, --tag, or --list can be used at one time.")
if args.local and not (args.public or args.tag):
raise ConsoleError("The --local option is only valid alongside --public or --tag.")
if args.instance and not (args.public or args.tag):
raise ConsoleError("The --instance option is only valid alongside --public or --tag.")
if args.public:
if args.instance:
return api.anon_public_timeline_generator(args.instance, local=args.local, limit=args.count)
else:
return api.public_timeline_generator(app, user, local=args.local, limit=args.count)
elif args.tag:
if args.instance:
return api.anon_tag_timeline_generator(args.instance, args.tag, limit=args.count)
else:
return api.tag_timeline_generator(app, user, args.tag, local=args.local, limit=args.count)
elif args.list:
return api.timeline_list_generator(app, user, args.list, limit=args.count)
else:
return api.home_timeline_generator(app, user, limit=args.count)
def timeline(app, user, args, generator=None):
if not generator:
generator = get_timeline_generator(app, user, args)
while True:
try:
items = next(generator)
except StopIteration:
print_out("That's all folks.")
return
if args.reverse:
items = reversed(items)
print_timeline(items)
if args.once or not sys.stdout.isatty():
break
char = input("\nContinue? [Y/n] ")
if char.lower() == "n":
break
def thread(app, user, args):
toot = api.single_status(app, user, args.status_id)
context = api.context(app, user, args.status_id)
thread = []
for item in context['ancestors']:
thread.append(item)
thread.append(toot)
for item in context['descendants']:
thread.append(item)
print_timeline(thread)
def post(app, user, args):
if args.editor and not sys.stdin.isatty():
raise ConsoleError("Cannot run editor if not in tty.")
if args.media and len(args.media) > 4:
raise ConsoleError("Cannot attach more than 4 files.")
media_ids = _upload_media(app, user, args)
status_text = _get_status_text(args.text, args.editor)
scheduled_at = _get_scheduled_at(args.scheduled_at, args.scheduled_in)
if not status_text and not media_ids:
raise ConsoleError("You must specify either text or media to post.")
response = api.post_status(
app, user, status_text,
visibility=args.visibility,
media_ids=media_ids,
sensitive=args.sensitive,
spoiler_text=args.spoiler_text,
in_reply_to_id=args.reply_to,
language=args.language,
scheduled_at=scheduled_at,
content_type=args.content_type
)
if "scheduled_at" in response:
scheduled_at = parse_datetime(response["scheduled_at"])
scheduled_at = datetime.strftime(scheduled_at, "%Y-%m-%d %H:%M:%S%z")
print_out(f"Toot scheduled for: <green>{scheduled_at}</green>")
else:
print_out(f"Toot posted: <green>{response['url']}")
def _get_status_text(text, editor):
isatty = sys.stdin.isatty()
if not text and not isatty:
text = sys.stdin.read().rstrip()
if isatty:
if editor:
text = editor_input(editor, text)
elif not text:
print_out("Write or paste your toot. Press <yellow>{}</yellow> to post it.".format(EOF_KEY))
text = multiline_input()
return text
def _get_scheduled_at(scheduled_at, scheduled_in):
if scheduled_at:
return scheduled_at
if scheduled_in:
scheduled_at = datetime.now(timezone.utc) + timedelta(seconds=scheduled_in)
return scheduled_at.replace(microsecond=0).isoformat()
return None
def _upload_media(app, user, args):
# Match media to corresponding description and upload
media = args.media or []
descriptions = args.description or []
uploaded_media = []
for idx, file in enumerate(media):
description = descriptions[idx].strip() if idx < len(descriptions) else None
result = _do_upload(app, user, file, description)
uploaded_media.append(result)
return [m["id"] for m in uploaded_media]
def delete(app, user, args):
api.delete_status(app, user, args.status_id)
print_out("<green>✓ Status deleted</green>")
def favourite(app, user, args):
api.favourite(app, user, args.status_id)
print_out("<green>✓ Status favourited</green>")
def unfavourite(app, user, args):
api.unfavourite(app, user, args.status_id)
print_out("<green>✓ Status unfavourited</green>")
def reblog(app, user, args):
api.reblog(app, user, args.status_id, visibility=args.visibility)
print_out("<green>✓ Status reblogged</green>")
def unreblog(app, user, args):
api.unreblog(app, user, args.status_id)
print_out("<green>✓ Status unreblogged</green>")
def pin(app, user, args):
api.pin(app, user, args.status_id)
print_out("<green>✓ Status pinned</green>")
def unpin(app, user, args):
api.unpin(app, user, args.status_id)
print_out("<green>✓ Status unpinned</green>")
def bookmark(app, user, args):
api.bookmark(app, user, args.status_id)
print_out("<green>✓ Status bookmarked</green>")
def unbookmark(app, user, args):
api.unbookmark(app, user, args.status_id)
print_out("<green>✓ Status unbookmarked</green>")
def bookmarks(app, user, args):
timeline(app, user, args, api.bookmark_timeline_generator(app, user, limit=args.count))
def reblogged_by(app, user, args):
for account in api.reblogged_by(app, user, args.status_id):
print_out("{}\n @{}".format(account['display_name'], account['acct']))
def auth(app, user, args):
config_data = config.load_config()
if not config_data["users"]:
print_out("You are not logged in to any accounts")
return
active_user = config_data["active_user"]
print_out("Authenticated accounts:")
for uid, u in config_data["users"].items():
active_label = "ACTIVE" if active_user == uid else ""
print_out("* <green>{}</green> <yellow>{}</yellow>".format(uid, active_label))
path = config.get_config_file_path()
print_out("\nAuth tokens are stored in: <blue>{}</blue>".format(path))
def env(app, user, args):
print_out(f"toot {__version__}")
print_out(f"Python {sys.version}")
print_out(platform.platform())
def login_cli(app, user, args):
app = create_app_interactive(instance=args.instance, scheme=args.scheme)
login_interactive(app, args.email)
print_out()
print_out("<green>✓ Successfully logged in.</green>")
def login(app, user, args):
app = create_app_interactive(instance=args.instance, scheme=args.scheme)
login_browser_interactive(app)
print_out()
print_out("<green>✓ Successfully logged in.</green>")
def logout(app, user, args):
user = config.load_user(args.account, throw=True)
config.delete_user(user)
print_out("<green>✓ User {} logged out</green>".format(config.user_id(user)))
def activate(app, user, args):
user = config.load_user(args.account, throw=True)
config.activate_user(user)
print_out("<green>✓ User {} active</green>".format(config.user_id(user)))
def upload(app, user, args):
response = _do_upload(app, user, args.file, args.description)
msg = "Successfully uploaded media ID <yellow>{}</yellow>, type '<yellow>{}</yellow>'"
print_out()
print_out(msg.format(response['id'], response['type']))
print_out("URL: <green>{}</green>".format(response['url']))
print_out("Preview URL: <green>{}</green>".format(response['preview_url']))
def search(app, user, args):
response = api.search(app, user, args.query, args.resolve)
print_search_results(response)
def _do_upload(app, user, file, description):
print_out("Uploading media: <green>{}</green>".format(file.name))
return api.upload_media(app, user, file, description=description)
def _find_account(app, user, account_name):
if not account_name:
raise ConsoleError("Empty account name given")
normalized_name = account_name.lstrip("@").lower()
# Strip @<instance_name> from accounts on the local instance. The `acct`
# field in account object contains the qualified name for users of other
# instances, but only the username for users of the local instance. This is
# required in order to match the account name below.
if "@" in normalized_name:
[username, instance] = normalized_name.split("@", maxsplit=1)
if instance == app.instance:
normalized_name = username
response = api.search(app, user, account_name, type="accounts", resolve=True)
for account in response["accounts"]:
if account["acct"].lower() == normalized_name:
return account
raise ConsoleError("Account not found")
def follow(app, user, args):
account = _find_account(app, user, args.account)
api.follow(app, user, account['id'])
print_out("<green>✓ You are now following {}</green>".format(args.account))
def unfollow(app, user, args):
account = _find_account(app, user, args.account)
api.unfollow(app, user, account['id'])
print_out("<green>✓ You are no longer following {}</green>".format(args.account))
def following(app, user, args):
account = _find_account(app, user, args.account)
response = api.following(app, user, account['id'])
print_acct_list(response)
def followers(app, user, args):
account = _find_account(app, user, args.account)
response = api.followers(app, user, account['id'])
print_acct_list(response)
def tags_follow(app, user, args):
tn = args.tag_name if not args.tag_name.startswith("#") else args.tag_name[1:]
api.follow_tag(app, user, tn)
print_out("<green>✓ You are now following #{}</green>".format(tn))
def tags_unfollow(app, user, args):
tn = args.tag_name if not args.tag_name.startswith("#") else args.tag_name[1:]
api.unfollow_tag(app, user, tn)
print_out("<green>✓ You are no longer following #{}</green>".format(tn))
def tags_followed(app, user, args):
response = api.followed_tags(app, user)
print_tag_list(response)
def mute(app, user, args):
account = _find_account(app, user, args.account)
api.mute(app, user, account['id'])
print_out("<green>✓ You have muted {}</green>".format(args.account))
def unmute(app, user, args):
account = _find_account(app, user, args.account)
api.unmute(app, user, account['id'])
print_out("<green>✓ {} is no longer muted</green>".format(args.account))
def block(app, user, args):
account = _find_account(app, user, args.account)
api.block(app, user, account['id'])
print_out("<green>✓ You are now blocking {}</green>".format(args.account))
def unblock(app, user, args):
account = _find_account(app, user, args.account)
api.unblock(app, user, account['id'])
print_out("<green>✓ {} is no longer blocked</green>".format(args.account))
def whoami(app, user, args):
account = api.verify_credentials(app, user)
print_account(account)
def whois(app, user, args):
account = _find_account(app, user, args.account)
print_account(account)
def instance(app, user, args):
name = args.instance or (app and app.instance)
if not name:
raise ConsoleError("Please specify instance name.")
try:
instance = api.get_instance(name, args.scheme)
print_instance(instance)
except ApiError:
raise ConsoleError(
"Instance not found at {}.\n"
"The given domain probably does not host a Mastodon instance.".format(name)
)
def notifications(app, user, args):
if args.clear:
api.clear_notifications(app, user)
print_out("<green>Cleared notifications</green>")
return
exclude = []
if args.mentions:
# Filter everything except mentions
# https://docs.joinmastodon.org/methods/notifications/
exclude = ["follow", "favourite", "reblog", "poll", "follow_request"]
notifications = api.get_notifications(app, user, exclude_types=exclude)
if not notifications:
print_out("<yellow>No notification</yellow>")
return
if args.reverse:
notifications = reversed(notifications)
print_notifications(notifications)
def tui(app, user, args):
from .tui.app import TUI
TUI.create(app, user, args).run()

Wyświetl plik

@ -1,44 +1,22 @@
import json
import os
import sys
from functools import wraps
from os.path import dirname, join, expanduser
from contextlib import contextmanager
from os.path import dirname, join
from typing import Optional
from toot import User, App
from toot import User, App, get_config_dir
from toot.exceptions import ConsoleError
from toot.output import print_out
TOOT_CONFIG_DIR_NAME = "toot"
TOOT_CONFIG_FILE_NAME = "config.json"
def get_config_dir():
"""Returns the path to toot config directory"""
# On Windows, store the config in roaming appdata
if sys.platform == "win32" and "APPDATA" in os.environ:
return join(os.getenv("APPDATA"), TOOT_CONFIG_DIR_NAME)
# Respect XDG_CONFIG_HOME env variable if set
# https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
if "XDG_CONFIG_HOME" in os.environ:
config_home = expanduser(os.environ["XDG_CONFIG_HOME"])
return join(config_home, TOOT_CONFIG_DIR_NAME)
# Default to ~/.config/toot/
return join(expanduser("~"), ".config", TOOT_CONFIG_DIR_NAME)
def get_config_file_path():
"""Returns the path to toot config file."""
return join(get_config_dir(), TOOT_CONFIG_FILE_NAME)
CONFIG_FILE = get_config_file_path()
def user_id(user):
return "{}@{}".format(user.username, user.instance)
@ -51,8 +29,6 @@ def make_config(path):
"active_user": None,
}
print_out("Creating config file at <blue>{}</blue>".format(path))
# Ensure dir exists
os.makedirs(dirname(path), exist_ok=True)
@ -63,15 +39,22 @@ def make_config(path):
def load_config():
if not os.path.exists(CONFIG_FILE):
make_config(CONFIG_FILE)
# Just to prevent accidentally running tests on production
if os.environ.get("TOOT_TESTING"):
raise Exception("Tests should not access the config file!")
with open(CONFIG_FILE) as f:
path = get_config_file_path()
if not os.path.exists(path):
make_config(path)
with open(path) as f:
return json.load(f)
def save_config(config):
with open(CONFIG_FILE, 'w') as f:
path = get_config_file_path()
with open(path, "w") as f:
return json.dump(config, f, indent=True, sort_keys=True)
@ -104,7 +87,7 @@ def get_user_app(user_id):
return extract_user_app(load_config(), user_id)
def load_app(instance):
def load_app(instance: str) -> Optional[App]:
config = load_config()
if instance in config['apps']:
return App(**config['apps'][instance])
@ -120,63 +103,44 @@ def load_user(user_id, throw=False):
raise ConsoleError("User '{}' not found".format(user_id))
def modify_config(f):
@wraps(f)
def wrapper(*args, **kwargs):
config = load_config()
config = f(config, *args, **kwargs)
save_config(config)
return config
return wrapper
def get_user_list():
config = load_config()
return config['users']
@modify_config
def save_app(config, app):
assert isinstance(app, App)
config['apps'][app.instance] = app._asdict()
return config
@contextmanager
def edit_config():
config = load_config()
yield config
save_config(config)
def save_app(app: App):
with edit_config() as config:
config['apps'][app.instance] = app._asdict()
@modify_config
def delete_app(config, app):
assert isinstance(app, App)
config['apps'].pop(app.instance, None)
return config
with edit_config() as config:
config['apps'].pop(app.instance, None)
@modify_config
def save_user(config, user, activate=True):
assert isinstance(user, User)
def save_user(user: User, activate=True):
with edit_config() as config:
config['users'][user_id(user)] = user._asdict()
config['users'][user_id(user)] = user._asdict()
if activate:
config['active_user'] = user_id(user)
if activate:
def delete_user(user: User):
with edit_config() as config:
config['users'].pop(user_id(user), None)
if config['active_user'] == user_id(user):
config['active_user'] = None
def activate_user(user: User):
with edit_config() as config:
config['active_user'] = user_id(user)
return config
@modify_config
def delete_user(config, user):
assert isinstance(user, User)
config['users'].pop(user_id(user), None)
if config['active_user'] == user_id(user):
config['active_user'] = None
return config
@modify_config
def activate_user(config, user):
assert isinstance(user, User)
config['active_user'] = user_id(user)
return config

Wyświetl plik

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

554
toot/entities.py 100644
Wyświetl plik

@ -0,0 +1,554 @@
"""
Dataclasses which represent entities returned by the Mastodon API.
Data classes my have an optional static method named `__toot_prepare__` which is
used when constructing the data class using `from_dict`. The method will be
called with the dict and may modify it and return a modified dict. This is used
to implement any pre-processing which may be required, e.g. to support
different versions of the Mastodon API.
"""
import dataclasses
from dataclasses import dataclass, is_dataclass
from datetime import date, datetime
from functools import lru_cache
from typing import Any, Dict, List, Optional, Tuple, Type, TypeVar, Union
from typing import get_type_hints
from toot.typing_compat import get_args, get_origin
from toot.utils import get_text
from toot.utils.datetime import parse_datetime
@dataclass
class AccountField:
"""
https://docs.joinmastodon.org/entities/Account/#Field
"""
name: str
value: str
verified_at: Optional[datetime]
@dataclass
class CustomEmoji:
"""
https://docs.joinmastodon.org/entities/CustomEmoji/
"""
shortcode: str
url: str
static_url: str
visible_in_picker: bool
category: str
@dataclass
class Account:
"""
https://docs.joinmastodon.org/entities/Account/
"""
id: str
username: str
acct: str
url: str
display_name: str
note: str
avatar: str
avatar_static: str
header: str
header_static: str
locked: bool
fields: List[AccountField]
emojis: List[CustomEmoji]
bot: bool
group: bool
discoverable: Optional[bool]
noindex: Optional[bool]
moved: Optional["Account"]
suspended: Optional[bool]
limited: Optional[bool]
created_at: datetime
last_status_at: Optional[date]
statuses_count: int
followers_count: int
following_count: int
source: Optional[dict]
@staticmethod
def __toot_prepare__(obj: Dict) -> Dict:
# Pleroma has not yet converted last_status_at from datetime to date
# so trim it here so it doesn't break when converting to date.
# See: https://git.pleroma.social/pleroma/pleroma/-/issues/1470
last_status_at = obj.get("last_status_at")
if last_status_at:
obj.update(last_status_at=obj["last_status_at"][:10])
return obj
@property
def note_plaintext(self) -> str:
return get_text(self.note)
@dataclass
class Application:
"""
https://docs.joinmastodon.org/entities/Status/#application
"""
name: str
website: Optional[str]
@dataclass
class MediaAttachment:
"""
https://docs.joinmastodon.org/entities/MediaAttachment/
"""
id: str
type: str
url: str
preview_url: str
remote_url: Optional[str]
meta: dict
description: str
blurhash: str
@dataclass
class StatusMention:
"""
https://docs.joinmastodon.org/entities/Status/#Mention
"""
id: str
username: str
url: str
acct: str
@dataclass
class StatusTag:
"""
https://docs.joinmastodon.org/entities/Status/#Tag
"""
name: str
url: str
@dataclass
class PollOption:
"""
https://docs.joinmastodon.org/entities/Poll/#Option
"""
title: str
votes_count: Optional[int]
@dataclass
class Poll:
"""
https://docs.joinmastodon.org/entities/Poll/
"""
id: str
expires_at: Optional[datetime]
expired: bool
multiple: bool
votes_count: int
voters_count: Optional[int]
options: List[PollOption]
emojis: List[CustomEmoji]
voted: Optional[bool]
own_votes: Optional[List[int]]
@dataclass
class PreviewCard:
"""
https://docs.joinmastodon.org/entities/PreviewCard/
"""
url: str
title: str
description: str
type: str
author_name: str
author_url: str
provider_name: str
provider_url: str
html: str
width: int
height: int
image: Optional[str]
embed_url: str
blurhash: Optional[str]
@dataclass
class FilterKeyword:
"""
https://docs.joinmastodon.org/entities/FilterKeyword/
"""
id: str
keyword: str
whole_word: str
@dataclass
class FilterStatus:
"""
https://docs.joinmastodon.org/entities/FilterStatus/
"""
id: str
status_id: str
@dataclass
class Filter:
"""
https://docs.joinmastodon.org/entities/Filter/
"""
id: str
title: str
context: List[str]
expires_at: Optional[datetime]
filter_action: str
keywords: List[FilterKeyword]
statuses: List[FilterStatus]
@dataclass
class FilterResult:
"""
https://docs.joinmastodon.org/entities/FilterResult/
"""
filter: Filter
keyword_matches: Optional[List[str]]
status_matches: Optional[str]
@dataclass
class Status:
"""
https://docs.joinmastodon.org/entities/Status/
"""
id: str
uri: str
created_at: datetime
account: Account
content: str
visibility: str
sensitive: bool
spoiler_text: str
media_attachments: List[MediaAttachment]
application: Optional[Application]
mentions: List[StatusMention]
tags: List[StatusTag]
emojis: List[CustomEmoji]
reblogs_count: int
favourites_count: int
replies_count: int
url: Optional[str]
in_reply_to_id: Optional[str]
in_reply_to_account_id: Optional[str]
reblog: Optional["Status"]
poll: Optional[Poll]
card: Optional[PreviewCard]
language: Optional[str]
text: Optional[str]
edited_at: Optional[datetime]
favourited: Optional[bool]
reblogged: Optional[bool]
muted: Optional[bool]
bookmarked: Optional[bool]
pinned: Optional[bool]
filtered: Optional[List[FilterResult]]
@property
def original(self) -> "Status":
return self.reblog or self
@staticmethod
def __toot_prepare__(obj: Dict) -> Dict:
# Pleroma has a bug where created_at is set to an empty string.
# To avoid marking created_at as optional, which would require work
# because we count on it always existing, set it to current datetime.
# Possible underlying issue:
# https://git.pleroma.social/pleroma/pleroma/-/issues/2851
if not obj["created_at"]:
obj["created_at"] = datetime.now().astimezone().isoformat()
return obj
@dataclass
class Report:
"""
https://docs.joinmastodon.org/entities/Report/
"""
id: str
action_taken: bool
action_taken_at: Optional[datetime]
category: str
comment: str
forwarded: bool
created_at: datetime
status_ids: Optional[List[str]]
rule_ids: Optional[List[str]]
target_account: Account
@dataclass
class Notification:
"""
https://docs.joinmastodon.org/entities/Notification/
"""
id: str
type: str
created_at: datetime
account: Account
status: Optional[Status]
report: Optional[Report]
@dataclass
class InstanceUrls:
streaming_api: str
@dataclass
class InstanceStats:
user_count: int
status_count: int
domain_count: int
@dataclass
class InstanceConfigurationStatuses:
max_characters: int
max_media_attachments: int
characters_reserved_per_url: int
@dataclass
class InstanceConfigurationMediaAttachments:
supported_mime_types: List[str]
image_size_limit: int
image_matrix_limit: int
video_size_limit: int
video_frame_rate_limit: int
video_matrix_limit: int
@dataclass
class InstanceConfigurationPolls:
max_options: int
max_characters_per_option: int
min_expiration: int
max_expiration: int
@dataclass
class InstanceConfiguration:
"""
https://docs.joinmastodon.org/entities/V1_Instance/#configuration
"""
statuses: InstanceConfigurationStatuses
media_attachments: InstanceConfigurationMediaAttachments
polls: InstanceConfigurationPolls
@dataclass
class Rule:
"""
https://docs.joinmastodon.org/entities/Rule/
"""
id: str
text: str
@dataclass
class Instance:
"""
https://docs.joinmastodon.org/entities/V1_Instance/
"""
uri: str
title: str
short_description: str
description: str
email: str
version: str
urls: InstanceUrls
stats: InstanceStats
thumbnail: Optional[str]
languages: List[str]
registrations: bool
approval_required: bool
invites_enabled: bool
configuration: InstanceConfiguration
contact_account: Optional[Account]
rules: List[Rule]
@dataclass
class Relationship:
"""
Represents the relationship between accounts, such as following / blocking /
muting / etc.
https://docs.joinmastodon.org/entities/Relationship/
"""
id: str
following: bool
showing_reblogs: bool
notifying: bool
languages: List[str]
followed_by: bool
blocking: bool
blocked_by: bool
muting: bool
muting_notifications: bool
requested: bool
domain_blocking: bool
endorsed: bool
note: str
@dataclass
class TagHistory:
"""
Usage statistics for given days (typically the past week).
https://docs.joinmastodon.org/entities/Tag/#history
"""
day: str
uses: str
accounts: str
@dataclass
class Tag:
"""
Represents a hashtag used within the content of a status.
https://docs.joinmastodon.org/entities/Tag/
"""
name: str
url: str
history: List[TagHistory]
following: Optional[bool]
@dataclass
class FeaturedTag:
"""
Represents a hashtag that is featured on a profile.
https://docs.joinmastodon.org/entities/FeaturedTag/
"""
id: str
name: str
url: str
statuses_count: int
last_status_at: datetime
# Generic data class instance
T = TypeVar("T")
class ConversionError(Exception):
"""Raised when conversion fails from JSON value to data class field."""
def __init__(
self,
data_class: Type,
field_name: str,
field_type: Type,
field_value: Optional[str]
):
super().__init__(
f"Failed converting field `{data_class.__name__}.{field_name}` "
+ f"of type `{field_type.__name__}` from value {field_value!r}"
)
def from_dict(cls: Type[T], data: Dict) -> T:
"""Convert a nested dict into an instance of `cls`."""
# Apply __toot_prepare__ if it exists
prepare = getattr(cls, '__toot_prepare__', None)
if prepare:
data = prepare(data)
def _fields():
for name, type, default in get_fields(cls):
value = data.get(name, default)
converted = _convert_with_error_handling(cls, name, type, value)
yield name, converted
return cls(**dict(_fields()))
@lru_cache(maxsize=100)
def get_fields(cls: Type) -> List[Tuple[str, Type, Any]]:
hints = get_type_hints(cls)
return [
(
field.name,
_prune_optional(hints[field.name]),
_get_default_value(field)
)
for field in dataclasses.fields(cls)
]
def from_dict_list(cls: Type[T], data: List[Dict]) -> List[T]:
return [from_dict(cls, x) for x in data]
def _get_default_value(field):
if field.default is not dataclasses.MISSING:
return field.default
if field.default_factory is not dataclasses.MISSING:
return field.default_factory()
return None
def _convert_with_error_handling(
data_class: Type,
field_name: str,
field_type: Type,
field_value: Optional[str]
):
try:
return _convert(field_type, field_value)
except ConversionError:
raise
except Exception:
raise ConversionError(data_class, field_name, field_type, field_value)
def _convert(field_type, value):
if value is None:
return None
if field_type in [str, int, bool, dict]:
return value
if field_type == datetime:
return parse_datetime(value)
if field_type == date:
return date.fromisoformat(value)
if get_origin(field_type) == list:
(inner_type,) = get_args(field_type)
return [_convert(inner_type, x) for x in value]
if is_dataclass(field_type):
return from_dict(field_type, value)
raise ValueError(f"Not implemented for type '{field_type}'")
def _prune_optional(field_type: Type) -> Type:
"""For `Optional[<type>]` returns the encapsulated `<type>`."""
if get_origin(field_type) == Union:
args = get_args(field_type)
if len(args) == 2 and args[1] == type(None): # noqa
return args[0]
return field_type

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,13 +81,41 @@ def post(app, user, path, headers=None, files=None, data=None, json=None, allow_
return anon_post(url, headers=headers, files=files, data=data, json=json, allow_redirects=allow_redirects)
def delete(app, user, path, data=None, headers=None):
def anon_put(url, headers=None, files=None, data=None, json=None, allow_redirects=True):
request = Request(method="PUT", url=url, headers=headers, files=files, data=data, json=json)
response = send_request(request, allow_redirects)
return process_response(response)
def put(app, user, path, headers=None, files=None, data=None, json=None, allow_redirects=True):
url = app.base_url + path
headers = headers or {}
headers["Authorization"] = f"Bearer {user.access_token}"
request = Request('DELETE', url, headers=headers, json=data)
return anon_put(url, headers=headers, files=files, data=data, json=json, allow_redirects=allow_redirects)
def patch(app, user, path, headers=None, files=None, data=None, json=None):
url = app.base_url + path
headers = headers or {}
headers["Authorization"] = f"Bearer {user.access_token}"
request = Request('PATCH', url, headers=headers, files=files, data=data, json=json)
response = send_request(request)
return process_response(response)
def delete(app, user, path, data=None, json=None, headers=None):
url = app.base_url + path
headers = headers or {}
headers["Authorization"] = f"Bearer {user.access_token}"
request = Request('DELETE', url, headers=headers, data=data, json=json)
response = send_request(request)
return process_response(response)

Wyświetl plik

@ -2,22 +2,12 @@ import json
import sys
from logging import getLogger
from requests import Request, RequestException, Response
from urllib.parse import urlencode
logger = getLogger('toot')
logger = getLogger("toot")
VERBOSE = "--verbose" in sys.argv
COLOR = "--no-color" not in sys.argv
if COLOR:
ANSI_RED = "\033[31m"
ANSI_GREEN = "\033[32m"
ANSI_YELLOW = "\033[33m"
ANSI_END_COLOR = "\033[0m"
else:
ANSI_RED = ""
ANSI_GREEN = ""
ANSI_YELLOW = ""
ANSI_END_COLOR = ""
def censor_secrets(headers):
@ -36,40 +26,42 @@ def truncate(line):
return line
def log_request(request):
def log_request(request: Request):
logger.debug(f" --> {request.method} {_url(request)}")
logger.debug(f">>> {ANSI_GREEN}{request.method} {request.url}{ANSI_END_COLOR}")
if request.headers:
if VERBOSE and request.headers:
headers = censor_secrets(request.headers)
logger.debug(f">>> HEADERS: {ANSI_GREEN}{headers}{ANSI_END_COLOR}")
logger.debug(f" --> HEADERS: {headers}")
if request.data:
if VERBOSE and request.data:
data = truncate(request.data)
logger.debug(f">>> DATA: {ANSI_GREEN}{data}{ANSI_END_COLOR}")
logger.debug(f" --> DATA: {data}")
if request.json:
if VERBOSE and request.json:
data = truncate(json.dumps(request.json))
logger.debug(f">>> JSON: {ANSI_GREEN}{data}{ANSI_END_COLOR}")
logger.debug(f" --> JSON: {data}")
if request.files:
logger.debug(f">>> FILES: {ANSI_GREEN}{request.files}{ANSI_END_COLOR}")
if VERBOSE and request.files:
logger.debug(f" --> FILES: {request.files}")
def log_response(response: Response):
method = response.request.method
url = response.request.url
elapsed = response.elapsed.microseconds // 1000
logger.debug(f" <-- {method} {url} HTTP {response.status_code} {elapsed}ms")
if VERBOSE and response.content:
content = truncate(response.content.decode())
logger.debug(f" <-- {content}")
def log_request_exception(request: Request, ex: RequestException):
logger.debug(f" <-- {request.method} {_url(request)} Exception: {ex}")
def _url(request):
url = request.url
if request.params:
logger.debug(f">>> PARAMS: {ANSI_GREEN}{request.params}{ANSI_END_COLOR}")
def log_response(response):
content = truncate(response.content.decode())
if response.ok:
logger.debug(f"<<< {ANSI_GREEN}{response}{ANSI_END_COLOR}")
logger.debug(f"<<< {ANSI_YELLOW}{content}{ANSI_END_COLOR}")
else:
logger.debug(f"<<< {ANSI_RED}{response}{ANSI_END_COLOR}")
logger.debug(f"<<< {ANSI_RED}{content}{ANSI_END_COLOR}")
def log_debug(*msgs):
logger.debug(" ".join(str(m) for m in msgs))
url += f"?{urlencode(request.params)}"
return url

Wyświetl plik

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

61
toot/settings.py 100644
Wyświetl plik

@ -0,0 +1,61 @@
from functools import lru_cache
from os.path import exists, join
from tomlkit import parse
from toot import get_config_dir
from typing import Optional, Type, TypeVar
DISABLE_SETTINGS = False
TOOT_SETTINGS_FILE_NAME = "settings.toml"
def get_settings_path():
return join(get_config_dir(), TOOT_SETTINGS_FILE_NAME)
def _load_settings() -> dict:
# Used for testing without config file
if DISABLE_SETTINGS:
return {}
path = get_settings_path()
if not exists(path):
return {}
with open(path) as f:
return parse(f.read())
@lru_cache(maxsize=None)
def get_settings():
return _load_settings()
T = TypeVar("T")
def get_setting(key: str, type: Type[T], default: Optional[T] = None) -> Optional[T]:
"""
Get a setting value. The key should be a dot-separated string,
e.g. "commands.post.editor" which will correspond to the "editor" setting
inside the `[commands.post]` section.
"""
settings = get_settings()
return _get_setting(settings, key.split("."), type, default)
def _get_setting(dct, keys, type: Type, default=None):
if len(keys) == 0:
if isinstance(dct, type):
return dct
else:
# TODO: warn? cast? both?
return default
key = keys[0]
if isinstance(dct, dict) and key in dct:
return _get_setting(dct[key], keys[1:], type, default)
return default

Wyświetl plik

@ -1,25 +1,43 @@
import logging
import subprocess
import urwid
from concurrent.futures import ThreadPoolExecutor
from typing import NamedTuple, Optional
from datetime import datetime, timezone
from toot import api, config, __version__
from toot.console import get_default_visibility
from toot import api, config, __version__, settings
from toot import App, User
from toot.cli import get_default_visibility
from toot.exceptions import ApiError
from toot.utils.datetime import parse_datetime
from .compose import StatusComposer
from .constants import PALETTE
from .entities import Status
from .overlays import ExceptionStackTrace, GotoMenu, Help, StatusSource, StatusLinks, StatusZoom
from .overlays import StatusDeleteConfirmation
from .overlays import StatusDeleteConfirmation, Account
from .poll import Poll
from .timeline import Timeline
from .utils import parse_content_links, show_media
from .utils import get_max_toot_chars, parse_content_links, copy_to_clipboard
from .widgets import ModalBox, RoundedLineBox
logger = logging.getLogger(__name__)
urwid.set_encoding('UTF-8')
DEFAULT_MAX_TOOT_CHARS = 500
class TuiOptions(NamedTuple):
colors: int
media_viewer: Optional[str]
always_show_sensitive: bool
relative_datetimes: bool
default_visibility: Optional[bool]
class Header(urwid.WidgetWrap):
def __init__(self, app, user):
self.app = app
@ -71,29 +89,41 @@ class Footer(urwid.Pile):
class TUI(urwid.Frame):
"""Main TUI frame."""
loop: urwid.MainLoop
screen: urwid.BaseScreen
@classmethod
def create(cls, app, user, args):
@staticmethod
def create(app: App, user: User, args: TuiOptions):
"""Factory method, sets up TUI and an event loop."""
screen = urwid.raw_display.Screen()
screen.set_terminal_properties(args.colors)
tui = TUI(app, user, screen, args)
palette = PALETTE.copy()
overrides = settings.get_setting("tui.palette", dict, {})
for name, styles in overrides.items():
palette.append(tuple([name] + styles))
tui = cls(app, user, args)
loop = urwid.MainLoop(
tui,
palette=PALETTE,
palette=palette,
event_loop=urwid.AsyncioEventLoop(),
unhandled_input=tui.unhandled_input,
screen=screen,
)
tui.loop = loop
return tui
def __init__(self, app, user, args):
def __init__(self, app, user, screen, options: TuiOptions):
self.app = app
self.user = user
self.args = args
self.config = config.load_config()
self.options = options
self.loop = None # set in `create`
self.loop = None # late init, set in `create`
self.screen = screen
self.executor = ThreadPoolExecutor(max_workers=1)
self.timeline_generator = api.home_timeline_generator(app, user, limit=40)
@ -104,20 +134,24 @@ class TUI(urwid.Frame):
self.footer.set_status("Loading...")
# Default max status length, updated on startup
self.max_toot_chars = 500
self.max_toot_chars = DEFAULT_MAX_TOOT_CHARS
self.timeline = None
self.overlay = None
self.exception = None
self.can_translate = False
self.account = None
self.followed_accounts = []
self.preferences = {}
super().__init__(self.body, header=self.header, footer=self.footer)
def run(self):
self.loop.set_alarm_in(0, lambda *args: self.async_load_instance())
self.loop.set_alarm_in(0, lambda *args: self.async_load_followed_tags())
self.loop.set_alarm_in(0, lambda *args: self.async_load_preferences())
self.loop.set_alarm_in(0, lambda *args: self.async_load_timeline(
is_initial=True, timeline_name="home"))
self.loop.set_alarm_in(0, lambda *args: self.async_load_followed_accounts())
self.loop.run()
self.executor.shutdown(wait=False)
@ -145,8 +179,8 @@ class TUI(urwid.Frame):
return urwid.Filler(intro)
def run_in_thread(self, fn, args=[], kwargs={}, done_callback=None, error_callback=None):
"""Runs `fn(*args, **kwargs)` asynchronously in a separate thread.
def run_in_thread(self, fn, done_callback=None, error_callback=None):
"""Runs `fn` asynchronously in a separate thread.
On completion calls `done_callback` if `fn` exited cleanly, or
`error_callback` if an exception was caught. Callback methods are
@ -155,7 +189,7 @@ class TUI(urwid.Frame):
def _default_error_callback(ex):
self.exception = ex
self.footer.set_error_message("An exception occurred, press E to view")
self.footer.set_error_message("An exception occurred, press X to view")
_error_callback = error_callback or _default_error_callback
@ -170,53 +204,15 @@ class TUI(urwid.Frame):
logger.exception(exception)
self.loop.set_alarm_in(0, lambda *args: _error_callback(exception))
future = self.executor.submit(fn, *args, **kwargs)
# TODO: replace by `self.loop.event_loop.run_in_executor` at some point
# Added in https://github.com/urwid/urwid/issues/575
# Not yet released at the time of this comment
future = self.loop.event_loop._loop.run_in_executor(self.executor, fn)
future.add_done_callback(_done)
return future
def connect_default_timeline_signals(self, timeline):
def _compose(*args):
self.show_compose()
def _delete(timeline, status):
if status.is_mine:
self.show_delete_confirmation(status)
def _reply(timeline, status):
self.show_compose(status)
def _source(timeline, status):
self.show_status_source(status)
def _links(timeline, status):
self.show_links(status)
def _media(timeline, status):
self.show_media(status)
def _menu(timeline, status):
self.show_context_menu(status)
def _zoom(timeline, status_details):
self.show_status_zoom(status_details)
def _clear(*args):
self.clear_screen()
urwid.connect_signal(timeline, "bookmark", self.async_toggle_bookmark)
urwid.connect_signal(timeline, "compose", _compose)
urwid.connect_signal(timeline, "delete", _delete)
urwid.connect_signal(timeline, "favourite", self.async_toggle_favourite)
urwid.connect_signal(timeline, "focus", self.refresh_footer)
urwid.connect_signal(timeline, "media", _media)
urwid.connect_signal(timeline, "menu", _menu)
urwid.connect_signal(timeline, "reblog", self.async_toggle_reblog)
urwid.connect_signal(timeline, "reply", _reply)
urwid.connect_signal(timeline, "source", _source)
urwid.connect_signal(timeline, "links", _links)
urwid.connect_signal(timeline, "zoom", _zoom)
urwid.connect_signal(timeline, "translate", self.async_translate)
urwid.connect_signal(timeline, "clear-screen", _clear)
def build_timeline(self, name, statuses, local):
def _close(*args):
@ -225,9 +221,6 @@ class TUI(urwid.Frame):
def _next(*args):
self.async_load_timeline(is_initial=False)
def _thread(timeline, status):
self.show_thread(status)
def _toggle_save(timeline, status):
if not timeline.name.startswith("#"):
return
@ -243,12 +236,11 @@ class TUI(urwid.Frame):
self.loop.set_alarm_in(5, lambda *args: self.footer.clear_message())
config.save_config(self.config)
timeline = Timeline(name, statuses, self.can_translate, self.followed_tags)
timeline = Timeline(self, name, statuses)
self.connect_default_timeline_signals(timeline)
urwid.connect_signal(timeline, "next", _next)
urwid.connect_signal(timeline, "close", _close)
urwid.connect_signal(timeline, "thread", _thread)
urwid.connect_signal(timeline, "save", _toggle_save)
return timeline
@ -266,19 +258,18 @@ class TUI(urwid.Frame):
# This is pretty fast, so it's probably ok to block while context is
# loaded, can be made async later if needed
context = api.context(self.app, self.user, status.original.id)
context = api.context(self.app, self.user, status.original.id).json()
ancestors = [self.make_status(s) for s in context["ancestors"]]
descendants = [self.make_status(s) for s in context["descendants"]]
statuses = ancestors + [status] + descendants
focus = len(ancestors)
timeline = Timeline("thread", statuses, self.can_translate,
self.followed_tags, focus, is_thread=True)
timeline = Timeline(self, "thread", statuses, focus=focus, is_thread=True)
self.connect_default_timeline_signals(timeline)
urwid.connect_signal(timeline, "close", _close)
self.body = timeline
timeline.refresh_status_details()
self.refresh_footer(timeline)
def async_load_timeline(self, is_initial, timeline_name=None, local=None):
@ -322,11 +313,11 @@ class TUI(urwid.Frame):
See: https://github.com/mastodon/mastodon/issues/19328
"""
def _load_instance():
return api.get_instance(self.app.instance)
return api.get_instance(self.app.base_url).json()
def _done(instance):
if "max_toot_chars" in instance:
self.max_toot_chars = instance["max_toot_chars"]
self.max_toot_chars = get_max_toot_chars(instance, DEFAULT_MAX_TOOT_CHARS)
logger.info(f"Max toot chars set to: {self.max_toot_chars}")
if "translation" in instance:
# instance is advertising translation service
@ -342,21 +333,33 @@ class TUI(urwid.Frame):
return self.run_in_thread(_load_instance, done_callback=_done)
def async_load_followed_tags(self):
def _load_tag_list():
def async_load_preferences(self):
"""
Attempt to update user preferences from instance.
https://docs.joinmastodon.org/methods/preferences/
"""
def _load_preferences():
return api.get_preferences(self.app, self.user).json()
def _done(preferences):
self.preferences = preferences
return self.run_in_thread(_load_preferences, done_callback=_done)
def async_load_followed_accounts(self):
def _load_accounts():
try:
return api.followed_tags(self.app, self.user)
acct = f'@{self.user.username}@{self.user.instance}'
self.account = api.find_account(self.app, self.user, acct)
return api.following(self.app, self.user, self.account["id"])
except ApiError:
# not supported by all Mastodon servers so fail silently if necessary
return []
def _done_tag_list(tags):
if len(tags) > 0:
self.followed_tags = [t["name"] for t in tags]
else:
self.followed_tags = []
def _done_accounts(accounts):
self.followed_accounts = {a["acct"] for a in accounts}
self.run_in_thread(_load_tag_list, done_callback=_done_tag_list)
self.run_in_thread(_load_accounts, done_callback=_done_accounts)
def refresh_footer(self, timeline):
"""Show status details in footer."""
@ -373,11 +376,11 @@ class TUI(urwid.Frame):
)
def clear_screen(self):
self.loop.screen.clear()
self.screen.clear()
def show_links(self, status):
links = parse_content_links(status.data["content"]) if status else []
post_attachments = status.data["media_attachments"] or []
links = parse_content_links(status.original.data["content"]) if status else []
post_attachments = status.original.data["media_attachments"] or []
reblog_attachments = (status.data["reblog"]["media_attachments"] if status.data["reblog"] else None) or []
for a in post_attachments + reblog_attachments:
@ -388,6 +391,8 @@ class TUI(urwid.Frame):
self.clear_screen()
if links:
links = list(set(links)) # deduplicate links
links = sorted(links, key=lambda link: link[0]) # sort alphabetically by URL
sl_widget = StatusLinks(links)
urwid.connect_signal(sl_widget, "clear-screen", _clear)
self.open_overlay(
@ -415,32 +420,81 @@ class TUI(urwid.Frame):
def _post(timeline, *args):
self.post_status(*args)
composer = StatusComposer(self.max_toot_chars, self.user.username, in_reply_to)
# If the user specified --default-visibility, use that; otherwise,
# try to use the server-side default visibility. If that fails, fall
# back to get_default_visibility().
visibility = (self.options.default_visibility or
self.preferences.get('posting:default:visibility',
get_default_visibility()))
composer = StatusComposer(self.max_toot_chars, self.user.username,
visibility, in_reply_to)
urwid.connect_signal(composer, "close", _close)
urwid.connect_signal(composer, "post", _post)
self.open_overlay(composer, title="Compose status")
def async_edit(self, status):
def _fetch_source():
return api.fetch_status_source(self.app, self.user, status.id).json()
def _done(source):
self.close_overlay()
self.show_edit(status, source)
please_wait = ModalBox("Loading status...")
self.open_overlay(please_wait)
self.run_in_thread(_fetch_source, done_callback=_done)
def show_edit(self, status, source):
def _close(*args):
self.close_overlay()
def _edit(timeline, *args):
self.edit_status(status, *args)
composer = StatusComposer(self.max_toot_chars, self.user.username,
visibility=None, edit=status, source=source)
urwid.connect_signal(composer, "close", _close)
urwid.connect_signal(composer, "post", _edit)
self.open_overlay(composer, title="Edit status")
def show_goto_menu(self):
user_timelines = self.config.get("timelines", {})
menu = GotoMenu(user_timelines)
user_lists = api.get_lists(self.app, self.user) or []
menu = GotoMenu(user_timelines, user_lists)
urwid.connect_signal(menu, "home_timeline",
lambda x: self.goto_home_timeline())
urwid.connect_signal(menu, "public_timeline",
lambda x, local: self.goto_public_timeline(local))
urwid.connect_signal(menu, "bookmark_timeline",
lambda x, local: self.goto_bookmarks())
urwid.connect_signal(menu, "notification_timeline",
lambda x, local: self.goto_notifications())
urwid.connect_signal(menu, "conversation_timeline",
lambda x, local: self.goto_conversations())
urwid.connect_signal(menu, "personal_timeline",
lambda x, local: self.goto_personal_timeline())
urwid.connect_signal(menu, "hashtag_timeline",
lambda x, tag, local: self.goto_tag_timeline(tag, local=local))
urwid.connect_signal(menu, "list_timeline",
lambda x, list_item: self.goto_list_timeline(list_item))
self.open_overlay(menu, title="Go to", options=dict(
align="center", width=("relative", 60),
valign="middle", height=10 + len(user_timelines),
valign="middle", height=18 + len(user_timelines) + len(user_lists),
))
def show_help(self):
self.open_overlay(Help(), title="Help")
def show_poll(self, status):
self.open_overlay(
widget=Poll(self.app, self.user, status),
title="Poll",
)
def goto_home_timeline(self):
self.timeline_generator = api.home_timeline_generator(
self.app, self.user, limit=40)
@ -450,7 +504,8 @@ class TUI(urwid.Frame):
def goto_public_timeline(self, local):
self.timeline_generator = api.public_timeline_generator(
self.app, self.user, local=local, limit=40)
promise = self.async_load_timeline(is_initial=True, timeline_name="public")
timeline_name = "local public" if local else "global public"
promise = self.async_load_timeline(is_initial=True, timeline_name=timeline_name)
promise.add_done_callback(lambda *args: self.close_overlay())
def goto_bookmarks(self):
@ -459,6 +514,21 @@ class TUI(urwid.Frame):
promise = self.async_load_timeline(is_initial=True, timeline_name="bookmarks")
promise.add_done_callback(lambda *args: self.close_overlay())
def goto_notifications(self):
self.timeline_generator = api.notification_timeline_generator(
self.app, self.user, limit=40)
promise = self.async_load_timeline(is_initial=True, timeline_name="notifications")
promise.add_done_callback(lambda *args: self.close_overlay())
def goto_conversations(self):
self.timeline_generator = api.conversation_timeline_generator(
self.app, self.user, limit=40
)
promise = self.async_load_timeline(
is_initial=True, timeline_name="conversations"
)
promise.add_done_callback(lambda *args: self.close_overlay())
def goto_tag_timeline(self, tag, local):
self.timeline_generator = api.tag_timeline_generator(
self.app, self.user, tag, local=local, limit=40)
@ -467,10 +537,37 @@ class TUI(urwid.Frame):
)
promise.add_done_callback(lambda *args: self.close_overlay())
def goto_personal_timeline(self):
account_name = f"{self.user.username}@{self.user.instance}"
self.timeline_generator = api.account_timeline_generator(
self.app, self.user, account_name, reblogs=True, limit=40)
promise = self.async_load_timeline(is_initial=True, timeline_name=f"personal {account_name}")
promise.add_done_callback(lambda *args: self.close_overlay())
def goto_list_timeline(self, list_item):
self.timeline_generator = api.timeline_list_generator(
self.app, self.user, list_item['id'], limit=40)
promise = self.async_load_timeline(
is_initial=True, timeline_name=f"\N{clipboard}{list_item['title']}")
promise.add_done_callback(lambda *args: self.close_overlay())
def show_media(self, status):
urls = [m["url"] for m in status.original.data["media_attachments"]]
if urls:
show_media(urls)
if not urls:
return
media_viewer = self.options.media_viewer
if media_viewer:
try:
subprocess.run([media_viewer] + urls)
except FileNotFoundError:
self.footer.set_error_message(f"Media viewer not found: '{media_viewer}'")
except Exception as ex:
self.exception = ex
self.footer.set_error_message("Failed invoking media viewer. Press X to see exception.")
else:
self.footer.set_error_message("Media viewer not configured")
def show_context_menu(self, status):
# TODO: show context menu
@ -488,15 +585,20 @@ class TUI(urwid.Frame):
urwid.connect_signal(widget, "close", _close)
urwid.connect_signal(widget, "delete", _delete)
self.open_overlay(widget, title="Delete status?", options=dict(
align="center", width=("relative", 60),
valign="middle", height=5,
align="center", width=30,
valign="middle", height=4,
))
def post_status(self, content, warning, visibility, in_reply_to_id):
data = api.post_status(self.app, self.user, content,
data = api.post_status(
self.app,
self.user,
content,
spoiler_text=warning,
visibility=visibility,
in_reply_to_id=in_reply_to_id)
in_reply_to_id=in_reply_to_id
).json()
status = self.make_status(data)
# TODO: fetch new items from the timeline?
@ -504,13 +606,55 @@ class TUI(urwid.Frame):
self.footer.set_message("Status posted {} \\o/".format(status.id))
self.close_overlay()
def edit_status(self, status, content, warning, visibility, in_reply_to_id):
# We don't support editing polls (yet), so to avoid losing the poll
# data from the original toot, copy it to the edit request.
poll_args = {}
poll = status.original.data.get('poll', None)
if poll is not None:
poll_args['poll_options'] = [o['title'] for o in poll['options']]
poll_args['poll_multiple'] = poll['multiple']
# Convert absolute expiry time into seconds from now.
expires_at = parse_datetime(poll['expires_at'])
expires_in = int((expires_at - datetime.now(timezone.utc)).total_seconds())
poll_args['poll_expires_in'] = expires_in
if 'hide_totals' in poll:
poll_args['poll_hide_totals'] = poll['hide_totals']
data = api.edit_status(
self.app,
self.user,
status.id,
content,
spoiler_text=warning,
visibility=visibility,
**poll_args
).json()
new_status = self.make_status(data)
self.footer.set_message("Status edited {} \\o/".format(status.id))
self.close_overlay()
if self.timeline is not None:
self.timeline.update_status(new_status)
def show_account(self, account_id):
account = api.whois(self.app, self.user, account_id)
relationship = api.get_relationship(self.app, self.user, account_id)
self.open_overlay(
widget=Account(self.app, self.user, account, relationship),
title="Account",
)
def async_toggle_favourite(self, timeline, status):
def _favourite():
logger.info("Favouriting {}".format(status))
api.favourite(self.app, self.user, status.id)
def _unfavourite():
logger.info("Unfavouriting {}".format(status))
api.unfavourite(self.app, self.user, status.id)
def _done(loop):
@ -527,18 +671,16 @@ class TUI(urwid.Frame):
def async_toggle_reblog(self, timeline, status):
def _reblog():
logger.info("Reblogging {}".format(status))
api.reblog(self.app, self.user, status.id, visibility=get_default_visibility())
api.reblog(self.app, self.user, status.original.id, visibility=get_default_visibility())
def _unreblog():
logger.info("Unreblogging {}".format(status))
api.unreblog(self.app, self.user, status.id)
api.unreblog(self.app, self.user, status.original.id)
def _done(loop):
# Create a new Status with flipped reblogged flag
new_data = status.data
new_data["reblogged"] = not status.reblogged
new_status = self.make_status(new_data)
new_status.original.reblogged = not status.original.reblogged
timeline.update_status(new_status)
# Check if status is rebloggable
@ -549,17 +691,16 @@ class TUI(urwid.Frame):
return
self.run_in_thread(
_unreblog if status.reblogged else _reblog,
_unreblog if status.original.reblogged else _reblog,
done_callback=_done
)
def async_translate(self, timeline, status):
def _translate():
logger.info("Translating {}".format(status))
self.footer.set_message("Translating status {}".format(status.id))
self.footer.set_message("Translating status {}".format(status.original.id))
try:
response = api.translate(self.app, self.user, status.id)
response = api.translate(self.app, self.user, status.original.id)
if response["content"]:
self.footer.set_message("Status translated")
else:
@ -574,25 +715,23 @@ class TUI(urwid.Frame):
def _done(response):
if response is not None:
status.translation = response["content"]
status.translated_from = response["detected_source_language"]
status.show_translation = True
status.original.translation = response["content"]
status.original.translated_from = response["detected_source_language"]
status.original.show_translation = True
timeline.update_status(status)
# If already translated, toggle showing translation
if status.translation:
status.show_translation = not status.show_translation
if status.original.translation:
status.original.show_translation = not status.original.show_translation
timeline.update_status(status)
else:
self.run_in_thread(_translate, done_callback=_done)
def async_toggle_bookmark(self, timeline, status):
def _bookmark():
logger.info("Bookmarking {}".format(status))
api.bookmark(self.app, self.user, status.id)
def _unbookmark():
logger.info("Unbookmarking {}".format(status))
api.unbookmark(self.app, self.user, status.id)
def _done(loop):
@ -616,6 +755,12 @@ class TUI(urwid.Frame):
return self.run_in_thread(_delete, done_callback=_done)
def copy_status(self, status):
# TODO: copy a better version of status content
# including URLs
copy_to_clipboard(self.screen, status.original.data["content"])
self.footer.set_message(f"Status {status.original.id} copied")
# --- Overlay handling -----------------------------------------------------
default_overlay_options = dict(
@ -624,7 +769,7 @@ class TUI(urwid.Frame):
)
def open_overlay(self, widget, options={}, title=""):
top_widget = urwid.LineBox(widget, title=title)
top_widget = RoundedLineBox(widget, title=title)
bottom_widget = self.body
_options = self.default_overlay_options.copy()
@ -640,12 +785,46 @@ class TUI(urwid.Frame):
def close_overlay(self):
self.body = self.overlay.bottom_w
self.overlay = None
if self.timeline:
self.timeline.refresh_status_details()
def refresh_timeline(self):
# No point in refreshing the bookmarks timeline
# and we don't have a good way to refresh a
# list timeline yet (no reference to list ID kept)
if (not self.timeline
or self.timeline.name == 'bookmarks'
or self.timeline.name.startswith("\N{clipboard}")):
return
if self.timeline.name.startswith("#"):
self.timeline_generator = api.tag_timeline_generator(
self.app, self.user, self.timeline.name[1:], limit=40)
elif self.timeline.name.startswith("\N{clipboard}"):
self.timeline_generator = api.tag_timeline_generator(
self.app, self.user, self.timeline.name[1:], limit=40)
else:
if self.timeline.name.endswith("public"):
self.timeline_generator = api.public_timeline_generator(
self.app, self.user, local=self.timeline.name.startswith("local"), limit=40)
elif self.timeline.name == "notifications":
self.timeline_generator = api.notification_timeline_generator(
self.app, self.user, limit=40)
elif self.timeline.name == "conversations":
self.timeline_generator = api.conversation_timeline_generator(
self.app, self.user, limit=40)
else:
# default to home timeline
self.timeline_generator = api.home_timeline_generator(
self.app, self.user, limit=40)
self.async_load_timeline(is_initial=True, timeline_name=self.timeline.name)
# --- Keys -----------------------------------------------------------------
def unhandled_input(self, key):
# TODO: this should not be in unhandled input
if key in ('e', 'E'):
if key in ('x', 'X'):
if self.exception:
self.show_exception(self.exception)
@ -653,15 +832,13 @@ class TUI(urwid.Frame):
if not self.overlay:
self.show_goto_menu()
elif key in ('h', 'H'):
elif key == '?':
if not self.overlay:
self.show_help()
elif key == ',':
if not self.overlay:
self.timeline_generator = api.home_timeline_generator(
self.app, self.user, limit=40)
self.async_load_timeline(is_initial=True, timeline_name=self.timeline.name)
self.refresh_timeline()
elif key == 'esc':
if self.overlay:

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,11 +32,34 @@ class StatusComposer(urwid.Frame):
self.cw_remove_button = Button("Remove content warning",
on_press=self.remove_content_warning)
self.visibility = get_default_visibility()
if edit:
if source is None:
text = edit.data["content"]
else:
text = source.get("text", edit.data["content"])
if 'spoiler_text' in source:
self.cw_edit = EditBox(multiline=True, allow_tab=True,
edit_text=source['spoiler_text'])
self.visibility = edit.data["visibility"]
else: # not edit
text = self.get_initial_text(in_reply_to)
self.visibility = (
in_reply_to.visibility if in_reply_to else visibility
)
self.content_edit = EditBox(
edit_text=text, edit_pos=len(text), multiline=True, allow_tab=True)
urwid.connect_signal(self.content_edit.edit, "change", self.text_changed)
self.char_count = urwid.Text(["0/{}".format(max_chars)])
self.visibility_button = Button("Visibility: {}".format(self.visibility),
on_press=self.choose_visibility)
self.post_button = Button("Post", on_press=self.post)
self.post_button = Button("Edit" if edit else "Post", on_press=self.post)
self.cancel_button = Button("Cancel", on_press=self.close)
contents = list(self.generate_list_items())
@ -64,8 +86,8 @@ class StatusComposer(urwid.Frame):
def generate_list_items(self):
if self.in_reply_to:
yield urwid.Text(("gray", "Replying to {}".format(self.in_reply_to.original.account)))
yield urwid.AttrWrap(urwid.Divider("-"), "gray")
yield urwid.Text(("dim", "Replying to {}".format(self.in_reply_to.original.account)))
yield urwid.AttrWrap(urwid.Divider("-"), "dim")
yield urwid.Text("Status message")
yield self.content_edit

Wyświetl plik

@ -1,8 +1,21 @@
# name, fg, bg, mono, fg_h, bg_h
# Color definitions are tuples of:
# - name
# - foreground (normal mode)
# - background (normal mode)
# - foreground (monochrome mode)
# - foreground (high color mode)
# - background (high color mode)
#
# See:
# http://urwid.org/tutorial/index.html#display-attributes
# http://urwid.org/manual/displayattributes.html#using-display-attributes
PALETTE = [
# Components
('button', 'white', 'black'),
('button_focused', 'light gray', 'dark magenta'),
('button_focused', 'light gray', 'dark magenta', 'bold,underline'),
('card_author', 'yellow', ''),
('card_title', 'dark green', ''),
('columns_divider', 'white', 'dark blue'),
('content_warning', 'white', 'dark magenta'),
('editbox', 'white', 'black'),
@ -12,32 +25,61 @@ PALETTE = [
('footer_status', 'white', 'dark blue'),
('footer_status_bold', 'white, bold', 'dark blue'),
('header', 'white', 'dark blue'),
('header_bold', 'white,bold', 'dark blue'),
('header_bold', 'white,bold', 'dark blue', 'bold'),
('intro_bigtext', 'yellow', ''),
('intro_smalltext', 'light blue', ''),
('poll_bar', 'white', 'dark blue'),
('status_detail_account', 'dark green', ''),
('status_detail_bookmarked', 'light red', ''),
('status_detail_timestamp', 'light blue', ''),
('status_list_account', 'dark green', ''),
('status_list_selected', 'white,bold', 'dark green', 'bold,underline'),
('status_list_timestamp', 'light blue', ''),
# Functional
('hashtag', 'light cyan,bold', ''),
('followed_hashtag', 'yellow,bold', ''),
('link', ',italics', ''),
('link_focused', ',italics', 'dark magenta'),
# Colors
('bold', ',bold', ''),
('blue', 'light blue', ''),
('blue_bold', 'light blue, bold', ''),
('blue_selected', 'white', 'dark blue'),
('cyan', 'dark cyan', ''),
('cyan_bold', 'dark cyan,bold', ''),
('gray', 'dark gray', ''),
('green', 'dark green', ''),
('green_selected', 'white,bold', 'dark green'),
('yellow', 'yellow', ''),
('yellow_bold', 'yellow,bold', ''),
('red', 'dark red', ''),
('account', 'dark green', ''),
('hashtag', 'light cyan,bold', '', 'bold'),
('hashtag_followed', 'yellow,bold', '', 'bold'),
('link', ',italics', '', ',italics'),
('link_focused', ',italics', 'dark magenta', "underline,italics"),
('shortcut', 'light blue', ''),
('shortcut_highlight', 'white,bold', '', 'bold'),
('warning', 'light red', ''),
('white_bold', 'white,bold', '')
# Visibility
('visibility_public', 'dark gray', ''),
('visibility_unlisted', 'white', ''),
('visibility_private', 'dark cyan', ''),
('visibility_direct', 'yellow', ''),
# Styles
('bold', ',bold', ''),
('dim', 'dark gray', ''),
('highlight', 'yellow', ''),
('success', 'dark green', ''),
# HTML tag styling
('a', ',italics', '', 'italics'),
# em tag is mapped to i
('i', ',italics', '', 'italics'),
# strong tag is mapped to b
('b', ',bold', '', 'bold'),
# special case for bold + italic nested tags
('bi', ',bold,italics', '', ',bold,italics'),
('u', ',underline', '', ',underline'),
('del', ',strikethrough', '', ',strikethrough'),
('code', 'light gray, standout', '', ',standout'),
('pre', 'light gray, standout', '', ',standout'),
('blockquote', 'light gray', '', ''),
('h1', ',bold', '', ',bold'),
('h2', ',bold', '', ',bold'),
('h3', ',bold', '', ',bold'),
('h4', ',bold', '', ',bold'),
('h5', ',bold', '', ',bold'),
('h6', ',bold', '', ',bold'),
('class_mention_hashtag', 'light cyan', '', ''),
('class_hashtag', 'light cyan', '', ''),
]
VISIBILITY_OPTIONS = [

Wyświetl plik

@ -1,6 +1,6 @@
from collections import namedtuple
from .utils import parse_datetime
from toot.utils.datetime import parse_datetime
Author = namedtuple("Author", ["account", "display_name", "username"])
@ -53,6 +53,10 @@ class Status:
self.id = self.data["id"]
self.account = self._get_account()
self.created_at = parse_datetime(data["created_at"])
if data["edited_at"]:
self.edited_at = parse_datetime(data["edited_at"])
else:
self.edited_at = None
self.author = self._get_author()
self.favourited = data.get("favourited", False)
self.reblogged = data.get("reblogged", False)

Wyświetl plik

@ -4,20 +4,39 @@ import urwid
import webbrowser
from toot import __version__
from .utils import highlight_keys
from .widgets import Button, EditBox, SelectableText
from toot import api
from toot.tui.utils import highlight_keys
from toot.tui.widgets import Button, EditBox, SelectableText
from toot.tui.richtext import html_to_widgets
class StatusSource(urwid.ListBox):
class StatusSource(urwid.Padding):
"""Shows status data, as returned by the server, as formatted JSON."""
def __init__(self, status):
source = json.dumps(status.data, indent=4)
lines = source.splitlines()
self.source = json.dumps(status.data, indent=4)
self.filename_edit = EditBox(caption="Filename: ", edit_text=f"status-{status.id}.json")
self.status_text = urwid.Text("")
walker = urwid.SimpleFocusListWalker([
urwid.Text(line) for line in lines
self.filename_edit,
Button("Save", on_press=self.save_json),
urwid.Divider(""),
urwid.Divider(" "),
urwid.Text(self.source)
])
super().__init__(walker)
frame = urwid.Frame(
body=urwid.ListBox(walker),
footer=self.status_text
)
super().__init__(frame)
def save_json(self, button):
filename = self.filename_edit.get_edit_text()
if filename:
with open(filename, "w") as f:
f.write(self.source)
self.status_text.set_text(("footer_message", f"Saved to {filename}"))
class StatusZoom(urwid.ListBox):
@ -62,15 +81,15 @@ class StatusDeleteConfirmation(urwid.ListBox):
signals = ["delete", "close"]
def __init__(self, status):
yes = SelectableText("Yes, send it to heck")
no = SelectableText("No, I'll spare it for now")
def _delete(_):
self._emit("delete")
urwid.connect_signal(yes, "click", lambda *args: self._emit("delete"))
urwid.connect_signal(no, "click", lambda *args: self._emit("close"))
def _close(_):
self._emit("close")
walker = urwid.SimpleFocusListWalker([
urwid.AttrWrap(yes, "", "blue_selected"),
urwid.AttrWrap(no, "", "blue_selected"),
Button("Yes, delete", on_press=_delete),
Button("No, cancel", on_press=_close),
])
super().__init__(walker)
@ -81,19 +100,24 @@ class GotoMenu(urwid.ListBox):
"public_timeline",
"hashtag_timeline",
"bookmark_timeline",
"notification_timeline",
"conversation_timeline",
"personal_timeline",
"list_timeline",
]
def __init__(self, user_timelines):
def __init__(self, user_timelines, user_lists):
self.hash_edit = EditBox(caption="Hashtag: ")
self.message_widget = urwid.Text("")
actions = list(self.generate_actions(user_timelines))
actions = list(self.generate_actions(user_timelines, user_lists))
walker = urwid.SimpleFocusListWalker(actions)
super().__init__(walker)
def get_hashtag(self):
return self.hash_edit.edit_text.strip()
return self.hash_edit.edit_text.strip().lstrip("#")
def generate_actions(self, user_timelines):
def generate_actions(self, user_timelines, user_lists):
def _home(button):
self._emit("home_timeline")
@ -103,35 +127,64 @@ class GotoMenu(urwid.ListBox):
def _global_public(button):
self._emit("public_timeline", False)
def _personal(button):
self._emit("personal_timeline", False)
def _bookmarks(button):
self._emit("bookmark_timeline", False)
def _notifications(button):
self._emit("notification_timeline", False)
def _conversations(button):
self._emit("conversation_timeline", False)
def _hashtag(local):
self.message_widget.set_text("")
hashtag = self.get_hashtag()
if hashtag:
self._emit("hashtag_timeline", hashtag, local)
else:
self.set_focus(4)
self.message_widget.set_text(("warning", "Hashtag name required"))
def mk_on_press_user_hashtag(tag, local):
def on_press(btn):
self._emit("hashtag_timeline", tag, local)
return on_press
def mk_on_press_user_list(list_item):
def on_press(btn):
self._emit("list_timeline", list_item)
return on_press
yield Button("Home timeline", on_press=_home)
for tag, cfg in user_timelines.items():
is_local = cfg["local"]
yield Button("#{}".format(tag) + (" (local)" if is_local else ""),
on_press=mk_on_press_user_hashtag(tag, is_local))
yield Button("Local public timeline", on_press=_local_public)
yield Button("Global public timeline", on_press=_global_public)
yield Button("Personal timeline", on_press=_personal)
yield Button("Bookmarks", on_press=_bookmarks)
yield Button("Notifications", on_press=_notifications)
yield Button("Conversations", on_press=_conversations)
if len(user_timelines):
yield urwid.Divider()
yield urwid.Text(("bold", "Shortcuts:"))
# show all hashtag shortcuts
for tag, cfg in sorted(user_timelines.items()):
is_local = cfg["local"]
yield Button(f"#{tag}" + (" (local)" if is_local else ""),
on_press=mk_on_press_user_hashtag(tag, is_local))
for list_item in user_lists:
yield Button(f"\N{clipboard}{list_item['title']}",
on_press=mk_on_press_user_list(list_item))
yield urwid.Divider()
yield self.hash_edit
yield Button("Local hashtag timeline", on_press=lambda x: _hashtag(True))
yield Button("Public hashtag timeline", on_press=lambda x: _hashtag(False))
yield urwid.Divider()
yield self.message_widget
class Help(urwid.Padding):
@ -143,15 +196,9 @@ class Help(urwid.Padding):
def generate_contents(self):
def h(text):
return highlight_keys(text, "cyan")
return highlight_keys(text, "shortcut")
def link(text, url):
attr_map = {"link": "link_focused"}
text = SelectableText([text, ("link", url)])
urwid.connect_signal(text, "click", lambda t: webbrowser.open(url))
return urwid.AttrMap(text, "", attr_map)
yield urwid.Text(("yellow_bold", "toot {}".format(__version__)))
yield urwid.Text(("bold", "toot {}".format(__version__)))
yield urwid.Divider()
yield urwid.Text(("bold", "General usage"))
yield urwid.Divider()
@ -164,9 +211,9 @@ class Help(urwid.Padding):
yield urwid.Divider()
yield urwid.Text(h(" [Q] - quit toot"))
yield urwid.Text(h(" [G] - go to - switch timelines"))
yield urwid.Text(h(" [P] - save/unsave (pin) current timeline"))
yield urwid.Text(h(" [E] - save/unsave (pin) current timeline"))
yield urwid.Text(h(" [,] - refresh current timeline"))
yield urwid.Text(h(" [H] - show this help"))
yield urwid.Text(h(" [?] - show this help"))
yield urwid.Divider()
yield urwid.Text(("bold", "Status keys"))
yield urwid.Divider()
@ -179,13 +226,139 @@ class Help(urwid.Padding):
yield urwid.Text(h(" [N] - Translate status if possible (toggle)"))
yield urwid.Text(h(" [R] - Reply to current status"))
yield urwid.Text(h(" [S] - Show text marked as sensitive"))
yield urwid.Text(h(" [M] - Show status media"))
yield urwid.Text(h(" [T] - Show status thread (replies)"))
yield urwid.Text(h(" [L] - Show the status links"))
yield urwid.Text(h(" [U] - Show the status data in JSON as received from the server"))
yield urwid.Text(h(" [V] - Open status in default browser"))
yield urwid.Text(h(" [Y] - Copy status to clipboard"))
yield urwid.Text(h(" [Z] - Open status in scrollable popup window"))
yield urwid.Divider()
yield urwid.Text(("bold", "Links"))
yield urwid.Divider()
yield link("Documentation: ", "https://toot.readthedocs.io/")
yield link("Documentation: ", "https://toot.bezdomni.net/")
yield link("Project home: ", "https://github.com/ihabunek/toot/")
class Account(urwid.ListBox):
"""Shows account data and provides various actions"""
def __init__(self, app, user, account, relationship):
self.app = app
self.user = user
self.account = account
self.relationship = relationship
self.last_action = None
self.setup_listbox()
def setup_listbox(self):
actions = list(self.generate_contents(self.account, self.relationship, self.last_action))
walker = urwid.SimpleListWalker(actions)
super().__init__(walker)
def generate_contents(self, account, relationship=None, last_action=None):
if self.last_action and not self.last_action.startswith("Confirm"):
yield Button(f"Confirm {self.last_action}", on_press=take_action, user_data=self)
yield Button("Cancel", on_press=cancel_action, user_data=self)
else:
if self.user.username == account["acct"]:
yield urwid.Text(("dim", "This is your account"))
else:
if relationship['requested']:
yield urwid.Text(("dim", "< Follow request is pending >"))
else:
yield Button("Unfollow" if relationship['following'] else "Follow",
on_press=confirm_action, user_data=self)
yield Button("Unmute" if relationship['muting'] else "Mute",
on_press=confirm_action, user_data=self)
yield Button("Unblock" if relationship['blocking'] else "Block",
on_press=confirm_action, user_data=self)
yield urwid.Divider("")
yield urwid.Divider()
yield urwid.Text([("account", f"@{account['acct']}"), f" {account['display_name']}"])
if account["note"]:
yield urwid.Divider()
widgetlist = html_to_widgets(account["note"])
for line in widgetlist:
yield (line)
yield urwid.Divider()
yield urwid.Text(["ID: ", ("highlight", f"{account['id']}")])
yield urwid.Text(["Since: ", ("highlight", f"{account['created_at'][:10]}")])
yield urwid.Divider()
if account["bot"]:
yield urwid.Text([("highlight", "Bot \N{robot face}")])
yield urwid.Divider()
if account["locked"]:
yield urwid.Text([("warning", "Locked \N{lock}")])
yield urwid.Divider()
if "suspended" in account and account["suspended"]:
yield urwid.Text([("warning", "Suspended \N{cross mark}")])
yield urwid.Divider()
if relationship["followed_by"]:
yield urwid.Text(("highlight", "Follows you \N{busts in silhouette}"))
yield urwid.Divider()
if relationship["blocked_by"]:
yield urwid.Text(("warning", "Blocks you \N{no entry}"))
yield urwid.Divider()
yield urwid.Text(["Followers: ", ("highlight", f"{account['followers_count']}")])
yield urwid.Text(["Following: ", ("highlight", f"{account['following_count']}")])
yield urwid.Text(["Statuses: ", ("highlight", f"{account['statuses_count']}")])
if account["fields"]:
for field in account["fields"]:
name = field["name"].title()
yield urwid.Divider()
yield urwid.Text([("bold", f"{name.rstrip(':')}"), ":"])
widgetlist = html_to_widgets(field["value"])
for line in widgetlist:
yield (line)
if field["verified_at"]:
yield urwid.Text(("success", "✓ Verified"))
yield urwid.Divider()
yield link("", account["url"])
def take_action(button: Button, self: Account):
action = button.get_label()
if action == "Confirm Follow":
self.relationship = api.follow(self.app, self.user, self.account["id"]).json()
elif action == "Confirm Unfollow":
self.relationship = api.unfollow(self.app, self.user, self.account["id"]).json()
elif action == "Confirm Mute":
self.relationship = api.mute(self.app, self.user, self.account["id"]).json()
elif action == "Confirm Unmute":
self.relationship = api.unmute(self.app, self.user, self.account["id"]).json()
elif action == "Confirm Block":
self.relationship = api.block(self.app, self.user, self.account["id"]).json()
elif action == "Confirm Unblock":
self.relationship = api.unblock(self.app, self.user, self.account["id"]).json()
self.last_action = None
self.setup_listbox()
def confirm_action(button: Button, self: Account):
self.last_action = button.get_label()
self.setup_listbox()
def cancel_action(button: Button, self: Account):
self.last_action = None
self.setup_listbox()
def link(text, url):
attr_map = {"link": "link_focused"}
text = SelectableText([text, ("link", url)])
urwid.connect_signal(text, "click", lambda t: webbrowser.open(url))
return urwid.AttrMap(text, "", attr_map)

105
toot/tui/poll.py 100644
Wyświetl plik

@ -0,0 +1,105 @@
import urwid
from toot import api
from toot.exceptions import ApiError
from toot.utils.datetime import parse_datetime
from .widgets import Button, CheckBox, RadioButton, RoundedLineBox
from .richtext import html_to_widgets
class Poll(urwid.ListBox):
"""View and vote on a poll"""
def __init__(self, app, user, status):
self.status = status
self.app = app
self.user = user
self.poll = status.original.data.get("poll")
self.button_group = []
self.api_exception = None
self.setup_listbox()
def setup_listbox(self):
actions = list(self.generate_contents(self.status))
walker = urwid.SimpleListWalker(actions)
super().__init__(walker)
def build_linebox(self, contents):
contents = urwid.Pile(list(contents))
contents = urwid.Padding(contents, left=1, right=1)
return RoundedLineBox(contents)
def vote(self, button_widget):
poll = self.status.original.data.get("poll")
choices = []
for idx, button in enumerate(self.button_group):
if button.get_state():
choices.append(idx)
if len(choices):
try:
response = api.vote(self.app, self.user, poll["id"], choices=choices)
self.status.original.data["poll"] = response
self.api_exception = None
self.poll["voted"] = True
self.poll["own_votes"] = choices
except ApiError as exception:
self.api_exception = exception
finally:
self.setup_listbox()
def generate_poll_detail(self):
poll = self.poll
self.button_group = [] # button group
for idx, option in enumerate(poll["options"]):
voted_for = (
poll["voted"] and poll["own_votes"] and idx in poll["own_votes"]
)
if poll["voted"] or poll["expired"]:
prefix = "" if voted_for else " "
yield urwid.Text(("dim", prefix + f'{option["title"]}'))
else:
if poll["multiple"]:
checkbox = CheckBox(f'{option["title"]}')
self.button_group.append(checkbox)
yield checkbox
else:
yield RadioButton(self.button_group, f'{option["title"]}')
yield urwid.Divider()
poll_detail = "Poll · {} votes".format(poll["votes_count"])
if poll["expired"]:
poll_detail += " · Closed"
if poll["expires_at"]:
expires_at = parse_datetime(poll["expires_at"]).strftime(
"%Y-%m-%d %H:%M"
)
poll_detail += " · Closes on {}".format(expires_at)
yield urwid.Text(("dim", poll_detail))
def generate_contents(self, status):
yield urwid.Divider()
widgetlist = html_to_widgets(status.data["content"])
for line in widgetlist:
yield (line)
yield urwid.Divider()
yield self.build_linebox(self.generate_poll_detail())
yield urwid.Divider()
if self.poll["voted"]:
yield urwid.Text(("grey", "< Already Voted >"))
elif not self.poll["expired"]:
yield Button("Vote", on_press=self.vote)
if self.api_exception:
yield urwid.Divider()
yield urwid.Text("warning", str(self.api_exception))

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

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